@assuredloop/orca-bot-kit 0.23.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/package.json +1 -1
- package/src/book.js +94 -3
- package/src/clear.js +141 -42
- package/src/cli.js +24 -0
- package/src/groom.js +17 -10
- package/src/hooks.js +67 -31
- package/src/message.js +15 -1
- package/src/name.js +138 -0
- package/src/skills.js +10 -1
- package/src/temp.js +17 -10
- package/src/up.js +49 -27
package/package.json
CHANGED
package/src/book.js
CHANGED
|
@@ -152,8 +152,13 @@ function takeLock(home) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The lock on `file`, waited for `waitMs` at most: see `takeLock` for why it is
|
|
157
|
+
* this one. `how` is SQLite's: `IMMEDIATE`, one holder at a time; `SHARED`,
|
|
158
|
+
* any number at once, none while an `EXCLUSIVE` is held or waited for; and
|
|
159
|
+
* `EXCLUSIVE`, alone, once every `SHARED` holder has let go.
|
|
160
|
+
*/
|
|
161
|
+
function lockOn(file, waitMs, how = 'IMMEDIATE') {
|
|
157
162
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
158
163
|
|
|
159
164
|
const db = new DatabaseSync(file);
|
|
@@ -161,7 +166,13 @@ function lockOn(file, waitMs) {
|
|
|
161
166
|
// A writer that arrives while another is working waits for it rather than
|
|
162
167
|
// failing at once, and gives up saying so rather than waiting for ever.
|
|
163
168
|
db.exec(`PRAGMA busy_timeout = ${waitMs}`);
|
|
164
|
-
|
|
169
|
+
if (how === 'SHARED') {
|
|
170
|
+
// A reading transaction holds its shared lock from its first read.
|
|
171
|
+
db.exec('BEGIN');
|
|
172
|
+
db.prepare('SELECT count(*) FROM sqlite_master').get();
|
|
173
|
+
} else {
|
|
174
|
+
db.exec(`BEGIN ${how}`);
|
|
175
|
+
}
|
|
165
176
|
} catch (error) {
|
|
166
177
|
db.close();
|
|
167
178
|
throw error;
|
|
@@ -233,6 +244,86 @@ export function takeMailboxTurn(home, session) {
|
|
|
233
244
|
}
|
|
234
245
|
}
|
|
235
246
|
|
|
247
|
+
/**
|
|
248
|
+
* A session's turn to have its Codex thread named (#480), beside its mailbox
|
|
249
|
+
* turn: `{ release }`, or undefined at once when another naming holds it, so
|
|
250
|
+
* that two turn ends close together type the name once.
|
|
251
|
+
*/
|
|
252
|
+
export const takeNameTurn = (home, session) => turnOn(home, session, 'name', 0);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A session's typing turn (#480, the architect's ruling), for what the kit
|
|
256
|
+
* types into the session's tab one key at a time: no line of the kit's may
|
|
257
|
+
* land in between and send what is there with its own return. Two locks
|
|
258
|
+
* beside its mailbox turn: `typing`, the gate, held for the whole typing, and
|
|
259
|
+
* `lines`, taken alone once the lines already on their way have gone in.
|
|
260
|
+
* Returns `{ release }`, or undefined when it did not come within `waitMs`,
|
|
261
|
+
* or the lines in flight did not finish within LINES_WAIT_MS.
|
|
262
|
+
*/
|
|
263
|
+
export function takeTypingTurn(home, session, waitMs) {
|
|
264
|
+
const gate = turnOn(home, session, 'typing', waitMs);
|
|
265
|
+
if (gate === undefined) return undefined;
|
|
266
|
+
let lines;
|
|
267
|
+
try {
|
|
268
|
+
lines = turnOn(home, session, 'lines', LINES_WAIT_MS, 'EXCLUSIVE');
|
|
269
|
+
} finally {
|
|
270
|
+
if (lines === undefined) gate.release();
|
|
271
|
+
}
|
|
272
|
+
if (lines === undefined) return undefined;
|
|
273
|
+
return {
|
|
274
|
+
release() {
|
|
275
|
+
try {
|
|
276
|
+
lines.release();
|
|
277
|
+
} finally {
|
|
278
|
+
gate.release();
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A session's turn for one whole line, sent with its return in one go, as the
|
|
286
|
+
* mail nudge is: through the typing turn's gate, waited for `waitMs` at most,
|
|
287
|
+
* and then held beside any other line, so whole lines never wait on each
|
|
288
|
+
* other, only on typing one key at a time. Returns `{ release }`, or undefined
|
|
289
|
+
* when the gate did not come within `waitMs`. Every whole line the kit types
|
|
290
|
+
* takes it: the nudge, the list line, the skills reload and the grooming line (#482).
|
|
291
|
+
*/
|
|
292
|
+
export function takeLineTurn(home, session, waitMs) {
|
|
293
|
+
const gate = turnOn(home, session, 'typing', waitMs);
|
|
294
|
+
if (gate === undefined) return undefined;
|
|
295
|
+
try {
|
|
296
|
+
return turnOn(home, session, 'lines', 0, 'SHARED');
|
|
297
|
+
} finally {
|
|
298
|
+
gate.release();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* How long a kit path waits for a session's typing turn before it types
|
|
304
|
+
* nothing (#480, #482): a naming holds it for seconds.
|
|
305
|
+
*/
|
|
306
|
+
export const TYPING_WAIT_MS = 5000;
|
|
307
|
+
|
|
308
|
+
/** What a kit path reports when the session's typing turn did not come within TYPING_WAIT_MS. */
|
|
309
|
+
export const TYPING_HELD = `the kit is typing into it, and it was still at it after ${TYPING_WAIT_MS / 1000} s, so nothing was typed`;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* How long a typing turn waits for the lines already on their way: a nudge
|
|
313
|
+
* holds its line's turn while Orca waits up to 5 s to see it start a turn.
|
|
314
|
+
*/
|
|
315
|
+
const LINES_WAIT_MS = 10_000;
|
|
316
|
+
|
|
317
|
+
/** A turn of the kit's own for one session, on a file beside its mailbox turn's. */
|
|
318
|
+
function turnOn(home, session, kind, waitMs, how) {
|
|
319
|
+
try {
|
|
320
|
+
return lockOn(mailboxLockFile(home, session).replace(/\.mailbox\.lock$/, `.${kind}.lock`), waitMs, how);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error.errcode === SQLITE_BUSY) return undefined;
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
236
327
|
/**
|
|
237
328
|
* How long a step, a check or an `up` waits for a session's turn. Longer than a
|
|
238
329
|
* step can hold it — three Orca calls of twenty seconds each at most — and short
|
package/src/clear.js
CHANGED
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
import { realpathSync, statSync } from 'node:fs';
|
|
15
15
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
16
16
|
|
|
17
|
-
import { readBook } from './book.js';
|
|
17
|
+
import { readBook, takeTypingTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
18
18
|
import { botDir, readBot } from './bot.js';
|
|
19
19
|
import { claudeTranscript, codexRollout } from './conversations.js';
|
|
20
20
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
21
21
|
import { eachLine, firstLine } from './lines.js';
|
|
22
|
-
import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto } from './orca.js';
|
|
22
|
+
import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
23
23
|
import { botsNamed, sessionsOf, typeListLine } from './up.js';
|
|
24
24
|
|
|
25
25
|
/** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes, section 3). */
|
|
@@ -58,6 +58,13 @@ const CODEX_BARE_LINE = '0.160.0';
|
|
|
58
58
|
/** How long the session is given to be idle before nothing is typed, and how long each look waits on it. */
|
|
59
59
|
const IDLE_WAIT_MS = 30_000;
|
|
60
60
|
const LOOK_MS = 2000;
|
|
61
|
+
/**
|
|
62
|
+
* How long each look between two characters waits on Orca's tui-idle, whose
|
|
63
|
+
* answer it does not use: an open slash menu keeps it from answering ok (live
|
|
64
|
+
* run 3 of #391). Short, so a naming holds the session's typing turn for
|
|
65
|
+
* seconds (#480).
|
|
66
|
+
*/
|
|
67
|
+
const CHAR_LOOK_MS = 250;
|
|
61
68
|
/** How long the screen is given to show the command typed, or Codex's question after `/new`. */
|
|
62
69
|
const SCREEN_MS = 3000;
|
|
63
70
|
/** How long the book is given to hold the new conversation, and a harness to record a compaction (the architect: up to 5 minutes). */
|
|
@@ -95,14 +102,20 @@ const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
|
|
|
95
102
|
export async function clearSession(bots, { bot, session }) {
|
|
96
103
|
const it = sessionToType(bots, bot, session);
|
|
97
104
|
const was = readBook(it.home).sessions[session]?.session ?? null;
|
|
98
|
-
const { command, handle } = await enter(it, 'clear');
|
|
105
|
+
const { command, handle, typing } = await enter(it, 'clear');
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
107
|
+
// Codex's question and the line after it are part of the clear, typed in
|
|
108
|
+
// the typing turn it took for the command.
|
|
109
|
+
try {
|
|
110
|
+
if (it.harness === 'codex') {
|
|
111
|
+
await answerWhereToRun(it, handle, command);
|
|
112
|
+
const line = await typeListLine(it.home, it.session, it.tabId, { line: RECORD_LINE, held: true });
|
|
113
|
+
if (!line.typed) {
|
|
114
|
+
throw new Error(`${it.name}: ${command} went in, but the line that starts the new conversation's first turn was not typed (${line.why}), and Codex reports a new conversation only at its first turn. The book still holds ${was ?? 'no conversation'}. Look at its tab.`);
|
|
115
|
+
}
|
|
105
116
|
}
|
|
117
|
+
} finally {
|
|
118
|
+
typing.release();
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
const until = Date.now() + CLEAR_MS;
|
|
@@ -128,9 +141,10 @@ export async function compactSession(bots, { bot, session }) {
|
|
|
128
141
|
// that ends while the kit waits for the session to be idle is the one
|
|
129
142
|
// before it (review of PR #466). So only what is written after counts.
|
|
130
143
|
let record;
|
|
131
|
-
const { typedAt } = await enter(it, 'compact', () => {
|
|
144
|
+
const { typedAt, typing } = await enter(it, 'compact', () => {
|
|
132
145
|
record = recordAt(it.harness, it.home, readBook(it.home).sessions[session]?.session ?? null);
|
|
133
146
|
});
|
|
147
|
+
typing.release();
|
|
134
148
|
|
|
135
149
|
const until = typedAt + COMPACT_MS;
|
|
136
150
|
for (;;) {
|
|
@@ -150,7 +164,7 @@ export async function compactSession(bots, { bot, session }) {
|
|
|
150
164
|
* The session as something to type into, once everything that refuses before
|
|
151
165
|
* Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
|
|
152
166
|
*/
|
|
153
|
-
function sessionToType(bots, bot, session) {
|
|
167
|
+
export function sessionToType(bots, bot, session) {
|
|
154
168
|
botsNamed(bots, bot);
|
|
155
169
|
const home = realpathSync(botDir(bots, bot));
|
|
156
170
|
const known = readBot(home, bot);
|
|
@@ -174,22 +188,58 @@ function sessionToType(bots, bot, session) {
|
|
|
174
188
|
* Type the harness's command for `verb` into the session's tab and press
|
|
175
189
|
* return, once it is idle and the screen shows the command typed and nothing
|
|
176
190
|
* else. `before` runs just before the command is typed. Returns the command,
|
|
177
|
-
* the handle it went into,
|
|
191
|
+
* the handle it went into, when it was typed, and the session's typing turn
|
|
192
|
+
* (#482), taken once it is idle, as the naming takes it, and held for the
|
|
193
|
+
* caller to release.
|
|
178
194
|
*/
|
|
179
195
|
async function enter(it, verb, before = () => {}) {
|
|
180
|
-
|
|
181
|
-
|
|
196
|
+
let typing;
|
|
197
|
+
try {
|
|
198
|
+
const typed = await typeCommand(it, COMMANDS[verb][it.harness], {
|
|
199
|
+
before: () => {
|
|
200
|
+
typing = takeTypingTurn(it.home, it.session, TYPING_WAIT_MS);
|
|
201
|
+
if (typing === undefined) throw new Error(`${it.name}: ${TYPING_HELD}. Run this again in a moment.`);
|
|
202
|
+
before();
|
|
203
|
+
},
|
|
204
|
+
cannot: (wrong) => (verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
|
|
205
|
+
});
|
|
206
|
+
return { ...typed, typing };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
typing?.release();
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Type `command` into the session's tab, `it` as `sessionToType` gives it, and
|
|
215
|
+
* press return, once it is idle and `check` finds nothing wrong with the
|
|
216
|
+
* screen: by default the slash menu's check, which a command with words after
|
|
217
|
+
* it does not pass, since Codex closes its menu at the space (#480). `before`
|
|
218
|
+
* runs just before the command is typed; `cannot` adds to a refusal what a
|
|
219
|
+
* wrong screen means; past `deadline`, a time, what was typed is taken back
|
|
220
|
+
* and nothing is entered. Returns the command, the handle it went into, and
|
|
221
|
+
* when it was typed.
|
|
222
|
+
*/
|
|
223
|
+
export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
|
|
182
224
|
// On Codex, what is typed cannot be read back once its menu is open, so
|
|
183
225
|
// nothing may be in its input line before: no draft for the command to join.
|
|
184
|
-
|
|
226
|
+
const emptyLine = (rows) => {
|
|
227
|
+
if (it.harness !== 'codex') return;
|
|
185
228
|
const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
|
|
186
229
|
if (!CODEX_EMPTY.includes(line)) {
|
|
187
230
|
throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
|
|
188
231
|
}
|
|
189
|
-
}
|
|
232
|
+
};
|
|
233
|
+
emptyLine((await idleTab(it)).rows);
|
|
190
234
|
const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
|
|
191
235
|
|
|
192
236
|
before();
|
|
237
|
+
// What `before` waited for can have changed the session: one more look,
|
|
238
|
+
// with no wait, before the first key (#480 review).
|
|
239
|
+
const ready = lookAt(it);
|
|
240
|
+
if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
|
|
241
|
+
emptyLine(ready.rows);
|
|
242
|
+
const { handle } = ready;
|
|
193
243
|
const typedAt = Date.now();
|
|
194
244
|
// One character a send, each after a look through the gate, so nothing goes
|
|
195
245
|
// in as one burst and nothing goes in once something asks a question
|
|
@@ -197,31 +247,63 @@ async function enter(it, verb, before = () => {}) {
|
|
|
197
247
|
// Between characters, and once more before the return, the look is the gate
|
|
198
248
|
// and the harness's own at-work row, not Orca's tui-idle: an open slash menu
|
|
199
249
|
// can stop it answering ok (live run 3).
|
|
250
|
+
// The characters Orca took, and so what is in the input line of ours.
|
|
200
251
|
let typed = 0;
|
|
252
|
+
// Taken back, one backspace a character, in one send, so the input line is
|
|
253
|
+
// as it was. Whatever stops the typing takes them back, a send Orca refused
|
|
254
|
+
// or a look that threw included (#480 review); a take-back Orca refuses
|
|
255
|
+
// too leaves nothing more the kit can do.
|
|
256
|
+
const takeBack = () => {
|
|
257
|
+
const keys = '\x7f'.repeat(typed);
|
|
258
|
+
typed = 0;
|
|
259
|
+
if (keys === '') return;
|
|
260
|
+
try {
|
|
261
|
+
send(handle, keys);
|
|
262
|
+
} catch {
|
|
263
|
+
// Said by the error that stopped the typing.
|
|
264
|
+
}
|
|
265
|
+
};
|
|
201
266
|
const lookAgain = async () => {
|
|
202
267
|
await pause(CHAR_GAP_MS);
|
|
203
268
|
const look = lookAt(it, { idle: false });
|
|
269
|
+
// Past the caller's deadline nothing more goes in: a hook stopped partway
|
|
270
|
+
// would leave the text in the input line (#480 review).
|
|
271
|
+
if (look.why === undefined && Date.now() > deadline) look.why = 'its time ran out';
|
|
204
272
|
if (look.why === undefined) return;
|
|
205
|
-
|
|
206
|
-
|
|
273
|
+
const sofar = command.slice(0, typed);
|
|
274
|
+
takeBack();
|
|
275
|
+
throw new Error(`${it.name}: ${command} was being typed, and after ${sofar} ${look.why}, so what was typed was taken back and nothing was entered.`);
|
|
207
276
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
277
|
+
try {
|
|
278
|
+
for (const char of command) {
|
|
279
|
+
if (typed > 0) await lookAgain();
|
|
280
|
+
try {
|
|
281
|
+
send(handle, char);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// A send Orca did not answer in time may have gone in all the same.
|
|
284
|
+
// Counted only where the line was empty before the first key, so the
|
|
285
|
+
// one backspace too many can take nothing of the user's (#480 review).
|
|
286
|
+
if (error.code === TIMED_OUT && it.harness === 'codex') typed += 1;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
typed += 1;
|
|
290
|
+
}
|
|
291
|
+
await lookAgain();
|
|
292
|
+
let wrong = await typedWrong(handle, it.harness, command, version, check);
|
|
293
|
+
if (wrong === undefined && Date.now() > deadline) wrong = { why: 'its time ran out' };
|
|
294
|
+
if (wrong !== undefined) {
|
|
295
|
+
takeBack();
|
|
296
|
+
throw new Error(`${it.name}: ${command} was typed but not entered, and was taken back, because ${wrong.why}.${cannot(wrong)}${shownEnd(wrong.rows)}`);
|
|
297
|
+
}
|
|
298
|
+
// A return of its own, not `--enter`: Orca's gate can refuse a line sent
|
|
299
|
+
// with `--enter` while it names a reason, and on Codex a return inside the
|
|
300
|
+
// text lands in the draft (helpers in the system tests, #329).
|
|
301
|
+
send(handle, '\r');
|
|
302
|
+
typed = 0;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
takeBack();
|
|
305
|
+
throw error;
|
|
220
306
|
}
|
|
221
|
-
// A return of its own, not `--enter`: Orca's gate can refuse a line sent
|
|
222
|
-
// with `--enter` while it names a reason, and on Codex a return inside the
|
|
223
|
-
// text lands in the draft (helpers in the system tests, #329).
|
|
224
|
-
send(handle, '\r');
|
|
225
307
|
return { command, handle, typedAt };
|
|
226
308
|
}
|
|
227
309
|
|
|
@@ -235,6 +317,7 @@ async function idleTab(it) {
|
|
|
235
317
|
const until = Date.now() + IDLE_WAIT_MS;
|
|
236
318
|
for (;;) {
|
|
237
319
|
const look = lookAt(it);
|
|
320
|
+
if (look.moved) throw new Error(`${it.name}: nothing was typed, because ${look.why}.`);
|
|
238
321
|
let why = look.why;
|
|
239
322
|
// The wait is a cutoff, not a count of looks: a yes that comes after it
|
|
240
323
|
// is too late (as for LIST_LINE, review of PR #421).
|
|
@@ -248,11 +331,23 @@ async function idleTab(it) {
|
|
|
248
331
|
/**
|
|
249
332
|
* One look through the typing gate at the session's tab: `{ handle }` when
|
|
250
333
|
* nothing on its screen asks a question and nothing says it is at work, and,
|
|
251
|
-
* with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal
|
|
334
|
+
* with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
|
|
335
|
+
* with `moved` when the book holds another tab for the session now, or, where
|
|
336
|
+
* `it.conversation` is given, another conversation.
|
|
252
337
|
* Throws when the tab holds no harness.
|
|
253
338
|
*/
|
|
254
339
|
function lookAt(it, { idle = true } = {}) {
|
|
255
|
-
|
|
340
|
+
// A restart can give the session a new tab while this waits or types, and
|
|
341
|
+
// a hook can outlive its Codex (#480): only the tab the book holds is the
|
|
342
|
+
// session's.
|
|
343
|
+
const entry = readBook(it.home).sessions[it.session];
|
|
344
|
+
if (entry?.tab !== it.tabId) return { moved: true, why: `the book no longer holds its tab ${it.tabId} for it, but ${entry?.tab ?? 'none'}` };
|
|
345
|
+
// And, for a caller typing for one conversation, a `/new` in the same tab
|
|
346
|
+
// gives the session another (#480 review).
|
|
347
|
+
if (it.conversation !== undefined && entry.session !== it.conversation) {
|
|
348
|
+
return { moved: true, why: `the book no longer holds the conversation ${it.conversation} for it, but ${entry.session ?? 'none'}` };
|
|
349
|
+
}
|
|
350
|
+
const found = tabToTypeInto(it.home, it.tabId, idle ? LOOK_MS : CHAR_LOOK_MS);
|
|
256
351
|
if (found.blocked !== undefined) {
|
|
257
352
|
return {
|
|
258
353
|
why: found.blocked === QUESTION_ON_SCREEN
|
|
@@ -274,13 +369,14 @@ function lookAt(it, { idle = true } = {}) {
|
|
|
274
369
|
|
|
275
370
|
/**
|
|
276
371
|
* Why the screen does not show `command` typed and ready, or undefined when it
|
|
277
|
-
* does:
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
372
|
+
* does, as `check` reads it: for a slash command alone, the input line, the
|
|
373
|
+
* lowest row the harness's pointer starts, reads the pointer and the command
|
|
374
|
+
* and nothing else, and the slash menu's selected row names the command.
|
|
375
|
+
* `menu` is set when the input line was right and the menu was not. Read
|
|
376
|
+
* again for SCREEN_MS while it is not so: a screen
|
|
281
377
|
* takes a moment to draw what was typed.
|
|
282
378
|
*/
|
|
283
|
-
async function typedWrong(handle, harness, command, version) {
|
|
379
|
+
async function typedWrong(handle, harness, command, version, check) {
|
|
284
380
|
const until = Date.now() + SCREEN_MS;
|
|
285
381
|
for (;;) {
|
|
286
382
|
const seen = screenRows(handle, READ_MS);
|
|
@@ -288,7 +384,7 @@ async function typedWrong(handle, harness, command, version) {
|
|
|
288
384
|
// a question that shows on it stops it at once (review of c1e5ba4).
|
|
289
385
|
const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
|
|
290
386
|
if (signal !== undefined) return { why: signal, rows: seen.rows };
|
|
291
|
-
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } :
|
|
387
|
+
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version);
|
|
292
388
|
if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
|
|
293
389
|
await pause(ASK_MS);
|
|
294
390
|
}
|
|
@@ -449,7 +545,10 @@ function shownEnd(rows) {
|
|
|
449
545
|
/** How many rows of the screen a refusal shows. */
|
|
450
546
|
const SHOWN_ROWS = 12;
|
|
451
547
|
|
|
548
|
+
/** How long Orca is given to take one send: a send it never answers must not hold the caller (#480 review). */
|
|
549
|
+
const SEND_MS = 5000;
|
|
550
|
+
|
|
452
551
|
/** Type `keys` into the tab as they are, with no return of Orca's. */
|
|
453
|
-
const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
|
|
552
|
+
const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys], { timeoutMs: SEND_MS });
|
|
454
553
|
|
|
455
554
|
const harnessName = (harness) => (harness === 'claude' ? 'Claude Code' : 'Codex');
|
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';
|
|
@@ -274,6 +275,11 @@ Usage:
|
|
|
274
275
|
nudges a send there left because it could not
|
|
275
276
|
tell from inside Codex's sandbox, and types each
|
|
276
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.
|
|
277
283
|
obk session mailbox --bots <path> --bot <bot> --session <name>
|
|
278
284
|
For the kit's own launch line, not for typing: run
|
|
279
285
|
in the session's own tab, it gives the session its
|
|
@@ -317,6 +323,7 @@ const COMMANDS = {
|
|
|
317
323
|
'session record': ['bots', 'bot'],
|
|
318
324
|
'session sent': ['bots', 'bot'],
|
|
319
325
|
'session nudge': ['bots', 'bot'],
|
|
326
|
+
'session name': ['bots', 'bot'],
|
|
320
327
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
321
328
|
'temp make': ['bots', 'name'],
|
|
322
329
|
'temp roles': ['bots'],
|
|
@@ -449,6 +456,7 @@ async function run(argv) {
|
|
|
449
456
|
// there included, so it goes before anything that can complain (ADR 0032).
|
|
450
457
|
if (command === SENT) return sent(path.resolve(values.bots));
|
|
451
458
|
if (command === NUDGE) return nudgeLeft();
|
|
459
|
+
if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
|
|
452
460
|
|
|
453
461
|
// One fleet, one identity, whatever spelling of its path was given (#164).
|
|
454
462
|
const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
|
|
@@ -517,6 +525,7 @@ const ANSWER_IT = [
|
|
|
517
525
|
const RECORD = 'session record';
|
|
518
526
|
const SENT = 'session sent';
|
|
519
527
|
const NUDGE = 'session nudge';
|
|
528
|
+
const NAME = 'session name';
|
|
520
529
|
|
|
521
530
|
/**
|
|
522
531
|
* What the kit's send hook says after a native message: a warning the session
|
|
@@ -556,6 +565,21 @@ function nudgeLeft() {
|
|
|
556
565
|
return 0;
|
|
557
566
|
}
|
|
558
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
|
+
|
|
559
583
|
/**
|
|
560
584
|
* What the kit's hook does with what the harness told it, and what it answers.
|
|
561
585
|
*
|
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/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/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/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
|
}
|
package/src/temp.js
CHANGED
|
@@ -28,7 +28,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
30
30
|
|
|
31
|
-
import { readBook, updateBook } from './book.js';
|
|
31
|
+
import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
|
|
32
32
|
import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
|
|
33
33
|
import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
34
34
|
import { sessionInTab } from './message.js';
|
|
@@ -353,16 +353,23 @@ export async function trustHooks(bots, { tab, name }) {
|
|
|
353
353
|
if (handle === undefined) {
|
|
354
354
|
throw new Error(`${run} has no tab open in Orca, so there is no review of its to answer. ${nothing}`);
|
|
355
355
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
356
|
+
// The session's turn for a line (#482), for the look and the keys.
|
|
357
|
+
const turn = takeLineTurn(caller.home, name, TYPING_WAIT_MS);
|
|
358
|
+
if (turn === undefined) throw new Error(`${run}: ${TYPING_HELD}.`);
|
|
359
|
+
try {
|
|
360
|
+
const seen = screenRows(handle);
|
|
361
|
+
if (seen.rows === undefined) {
|
|
362
|
+
throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its hooks review is there. ${nothing}`);
|
|
363
|
+
}
|
|
364
|
+
const keys = keysToTrustAll(seen.rows);
|
|
365
|
+
if (keys === undefined) {
|
|
366
|
+
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}`);
|
|
367
|
+
}
|
|
368
|
+
// The return is inside the text: `--enter` would be a second key.
|
|
369
|
+
orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
|
|
370
|
+
} finally {
|
|
371
|
+
turn.release();
|
|
363
372
|
}
|
|
364
|
-
// The return is inside the text: `--enter` would be a second key.
|
|
365
|
-
orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
|
|
366
373
|
for (let waited = 0; ; waited += 500) {
|
|
367
374
|
const after = screenRows(handle);
|
|
368
375
|
if (after.rows !== undefined && !after.rows.some((row) => row.includes(HOOKS_REVIEW))) break;
|
package/src/up.js
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
10
10
|
|
|
11
|
-
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeMailboxTurn, updateBook, withUnclaimed } from './book.js';
|
|
11
|
+
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
|
|
12
12
|
import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
|
|
13
13
|
import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
@@ -374,7 +374,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
374
374
|
// 0.157.1 (#226). So it is given one line, through the same gate as a mail
|
|
375
375
|
// notice, which the owner allowed. A fresh start's first turn is its prompt.
|
|
376
376
|
const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
|
|
377
|
-
? await typeListLine(home, made.tabId)
|
|
377
|
+
? await typeListLine(home, session.name, made.tabId)
|
|
378
378
|
: undefined;
|
|
379
379
|
|
|
380
380
|
return entry(made, {
|
|
@@ -408,43 +408,65 @@ const LIST_LOOK_MS = 2000;
|
|
|
408
408
|
const LIST_ASK_MS = 500;
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
|
-
* Type `line`, LIST_LINE unless told otherwise, into the tab `tabId`
|
|
412
|
-
* gate every typed line goes through lets it:
|
|
413
|
-
* `{ typed: false, why }` with the gate's last reason
|
|
414
|
-
* Nothing goes into a question, a form or a menu
|
|
415
|
-
* the kit cannot tell about.
|
|
411
|
+
* Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` of
|
|
412
|
+
* `session` once the gate every typed line goes through lets it:
|
|
413
|
+
* `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
|
|
414
|
+
* once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
|
|
415
|
+
* (#329, #416), nor into a tab the kit cannot tell about. Each look and its
|
|
416
|
+
* line are taken in the session's turn for a line (#482), unless the caller
|
|
417
|
+
* says it holds the session's typing turn already (`held`).
|
|
416
418
|
*/
|
|
417
|
-
export async function typeListLine(home, tabId, line = LIST_LINE) {
|
|
419
|
+
export async function typeListLine(home, session, tabId, { line = LIST_LINE, held = false } = {}) {
|
|
418
420
|
const until = Date.now() + LIST_WAIT_MS;
|
|
419
421
|
for (;;) {
|
|
420
|
-
let
|
|
421
|
-
|
|
422
|
-
// Each look waits no longer than what is left of the wait.
|
|
423
|
-
found = tabToTypeInto(home, tabId, Math.max(1, Math.min(LIST_LOOK_MS, until - Date.now())));
|
|
424
|
-
} catch (error) {
|
|
425
|
-
return { typed: false, why: `Orca would not say whether it may be typed into: ${error.message}` };
|
|
426
|
-
}
|
|
427
|
-
// A yes that comes after the wait is too late: the wait is a cutoff, not a
|
|
428
|
-
// count of looks (review of PR #421).
|
|
429
|
-
if (found.handle !== undefined && Date.now() > until) {
|
|
430
|
-
return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out before the kit could tell it may be typed into` };
|
|
431
|
-
}
|
|
432
|
-
if (found.handle !== undefined) {
|
|
422
|
+
let turn;
|
|
423
|
+
if (!held) {
|
|
433
424
|
try {
|
|
434
|
-
|
|
435
|
-
return { typed: true };
|
|
425
|
+
turn = takeLineTurn(home, session, TYPING_WAIT_MS);
|
|
436
426
|
} catch (error) {
|
|
437
|
-
return { typed: false, why:
|
|
427
|
+
return { typed: false, why: error.message };
|
|
438
428
|
}
|
|
429
|
+
if (turn === undefined) return { typed: false, why: TYPING_HELD };
|
|
439
430
|
}
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
431
|
+
try {
|
|
432
|
+
const done = lookAndType(home, tabId, line, until);
|
|
433
|
+
if (done !== undefined) return done;
|
|
434
|
+
} finally {
|
|
435
|
+
turn?.release();
|
|
443
436
|
}
|
|
444
437
|
await pause(LIST_ASK_MS);
|
|
445
438
|
}
|
|
446
439
|
}
|
|
447
440
|
|
|
441
|
+
/** One look of typeListLine's, and its line when the gate lets it: what it returns, or undefined to look again. */
|
|
442
|
+
function lookAndType(home, tabId, line, until) {
|
|
443
|
+
let found;
|
|
444
|
+
try {
|
|
445
|
+
// Each look waits no longer than what is left of the wait.
|
|
446
|
+
found = tabToTypeInto(home, tabId, Math.max(1, Math.min(LIST_LOOK_MS, until - Date.now())));
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return { typed: false, why: `Orca would not say whether it may be typed into: ${error.message}` };
|
|
449
|
+
}
|
|
450
|
+
// A yes that comes after the wait is too late: the wait is a cutoff, not a
|
|
451
|
+
// count of looks (review of PR #421).
|
|
452
|
+
if (found.handle !== undefined && Date.now() > until) {
|
|
453
|
+
return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out before the kit could tell it may be typed into` };
|
|
454
|
+
}
|
|
455
|
+
if (found.handle !== undefined) {
|
|
456
|
+
try {
|
|
457
|
+
typeIntoTab(found.handle, line);
|
|
458
|
+
return { typed: true };
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return { typed: false, why: `Orca refused the line: ${error.message}` };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (Date.now() >= until) {
|
|
464
|
+
const last = found.blocked !== undefined ? `it is waiting on ${found.blocked}` : found.unsure ?? 'no harness is running in it';
|
|
465
|
+
return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out: ${last}` };
|
|
466
|
+
}
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
448
470
|
/**
|
|
449
471
|
* The line that starts this session: resuming `resume`, or, with none, a fresh
|
|
450
472
|
* start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
|