@assuredloop/orca-bot-kit 0.26.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -41
- package/SETUP.md +26 -13
- package/package.json +1 -1
- package/rules/mail.md +8 -8
- package/skills/obk-bot-building/SKILL.md +24 -15
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +143 -23
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +129 -130
- package/src/commands.js +21 -14
- package/src/conversations.js +73 -40
- package/src/groom.js +27 -9
- package/src/health.js +79 -11
- package/src/hooks.js +42 -64
- package/src/init.js +36 -29
- package/src/launch.js +221 -20
- package/src/message.js +83 -335
- package/src/name.js +1 -1
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +35 -42
- package/src/permissions.js +30 -16
- package/src/pi.js +55 -0
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/rules.js +5 -2
- package/src/skills.js +51 -35
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +80 -14
- package/src/unread.js +0 -160
package/src/conversations.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// the kit never recorded must not be mistaken for a session that never had one,
|
|
7
7
|
// so when the book cannot say, the harness is asked instead.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Every harness keeps its own record of every conversation, per working
|
|
10
10
|
// directory, and every session starts at its bot home. Read only, never
|
|
11
11
|
// written: these are the harness's files (tech notes, sections 2 and 3).
|
|
12
12
|
|
|
13
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
@@ -22,6 +22,38 @@ const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(hom
|
|
|
22
22
|
/** The folder's name is the working directory with everything else turned into a dash. */
|
|
23
23
|
const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Pi's sessions: one folder per working directory, named from its real path
|
|
27
|
+
* with the leading `/` taken off and `/`, `\` and `:` turned into dashes,
|
|
28
|
+
* between `--`; one `<time>_<id>.jsonl` per conversation (Pi 1.1.0,
|
|
29
|
+
* `getDefaultSessionDirPath`, tech notes, section 6).
|
|
30
|
+
*/
|
|
31
|
+
const piDir = (home) => path.join(homedir(), '.pi', 'agent', 'sessions', `--${realHome(home).replace(/^[/\\]/, '').replaceAll(/[/\\:]/g, '-')}--`);
|
|
32
|
+
|
|
33
|
+
/** The folder as Pi sees it from inside: the real path, when there is one. */
|
|
34
|
+
function realHome(home) {
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(home);
|
|
37
|
+
} catch {
|
|
38
|
+
return home;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A Pi session file's name, `<time>_<id>.jsonl`: the time is Pi's ISO time
|
|
44
|
+
* with `:` and `.` turned into dashes, and the id may hold a `_` of its own.
|
|
45
|
+
*/
|
|
46
|
+
const PI_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_(.+)\.jsonl$/;
|
|
47
|
+
|
|
48
|
+
/** The id a Pi session file's name carries, or undefined for a file that is not one. */
|
|
49
|
+
const piIdOf = (name) => PI_FILE.exec(name)?.[1];
|
|
50
|
+
|
|
51
|
+
/** Where Pi keeps the conversation `id` of this bot home, or undefined when it has none yet. */
|
|
52
|
+
const piSession = (home, id) => {
|
|
53
|
+
const name = files(piDir(home)).find((one) => piIdOf(one) === id);
|
|
54
|
+
return name === undefined ? undefined : path.join(piDir(home), name);
|
|
55
|
+
};
|
|
56
|
+
|
|
25
57
|
/** Codex's rollouts: a file per conversation, filed by the day it started. */
|
|
26
58
|
const codexDir = () => path.join(homedir(), '.codex', 'sessions');
|
|
27
59
|
|
|
@@ -52,6 +84,7 @@ export function conversationsIn(harness, home, since) {
|
|
|
52
84
|
*/
|
|
53
85
|
export function hasConversation(harness, home, id) {
|
|
54
86
|
if (harness === 'claude') return existsSync(claudeTranscript(home, id));
|
|
87
|
+
if (harness === 'pi') return piSession(home, id) !== undefined;
|
|
55
88
|
return codexRollout(id) !== undefined;
|
|
56
89
|
}
|
|
57
90
|
|
|
@@ -120,7 +153,9 @@ export function claudeSubagentTranscripts(file) {
|
|
|
120
153
|
*/
|
|
121
154
|
export function transcriptsIn(harness, home, since) {
|
|
122
155
|
const from = since === undefined ? 0 : Date.parse(since);
|
|
123
|
-
const found = harness === 'claude' ? claudeConversations(home)
|
|
156
|
+
const found = harness === 'claude' ? claudeConversations(home)
|
|
157
|
+
: harness === 'pi' ? piConversations(home)
|
|
158
|
+
: codexConversations(home, from);
|
|
124
159
|
return found
|
|
125
160
|
.filter((one) => Number.isNaN(from) || one.at >= from)
|
|
126
161
|
.sort((left, right) => left.at - right.at)
|
|
@@ -159,43 +194,6 @@ export function heldAsUserTurn(harness, file, text) {
|
|
|
159
194
|
return held;
|
|
160
195
|
}
|
|
161
196
|
|
|
162
|
-
/**
|
|
163
|
-
* Where the harness keeps the conversation `id` of this bot home, and how long
|
|
164
|
-
* that record is now: `{ harness, file, size }`, so a later look reads only
|
|
165
|
-
* what was written after this moment. Undefined when there is no id, no
|
|
166
|
-
* record, or it cannot be read (#509).
|
|
167
|
-
*/
|
|
168
|
-
export function recordMark(harness, home, id) {
|
|
169
|
-
if (typeof id !== 'string' || id === '') return undefined;
|
|
170
|
-
try {
|
|
171
|
-
const file = harness === 'codex' ? codexRollout(id) : claudeTranscript(home, id);
|
|
172
|
-
if (file === undefined) return undefined;
|
|
173
|
-
const found = statSync(file);
|
|
174
|
-
return found.isFile() ? { harness, file, size: found.size } : undefined;
|
|
175
|
-
} catch {
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Whether a turn of the user's written after `mark` holds `text` anywhere in
|
|
182
|
-
* it (#509). A record that cannot be read says no: not seeing a thing is not
|
|
183
|
-
* seeing it.
|
|
184
|
-
*/
|
|
185
|
-
export function userTurnSince(mark, text) {
|
|
186
|
-
if (mark === undefined || mark === null) return false;
|
|
187
|
-
let held = false;
|
|
188
|
-
try {
|
|
189
|
-
eachLine(mark.file, (line) => {
|
|
190
|
-
held = line !== undefined && userTexts(mark.harness, line).some((said) => said.includes(text));
|
|
191
|
-
return held;
|
|
192
|
-
}, { from: mark.size });
|
|
193
|
-
} catch {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
return held;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
197
|
/** The texts of one record line, when it is a turn of the user's. */
|
|
200
198
|
function userTexts(harness, line) {
|
|
201
199
|
let parsed;
|
|
@@ -210,6 +208,14 @@ function userTexts(harness, line) {
|
|
|
210
208
|
if (typeof content === 'string') return [content];
|
|
211
209
|
return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
|
|
212
210
|
}
|
|
211
|
+
if (harness === 'pi') {
|
|
212
|
+
// A turn is a `message` line, and the user's are role `user` (Pi 1.1.0,
|
|
213
|
+
// docs/session-format.md).
|
|
214
|
+
const message = parsed?.type === 'message' ? parsed.message : undefined;
|
|
215
|
+
if (message?.role !== 'user') return [];
|
|
216
|
+
if (typeof message.content === 'string') return [message.content];
|
|
217
|
+
return Array.isArray(message.content) ? message.content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
|
|
218
|
+
}
|
|
213
219
|
const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
|
|
214
220
|
if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
|
|
215
221
|
return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
|
|
@@ -228,6 +234,33 @@ function claudeConversations(home) {
|
|
|
228
234
|
});
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Pi's conversations in this bot home. The first line of each is its header,
|
|
239
|
+
* `{ type: 'session', id, timestamp, cwd }`, and says which conversation it is
|
|
240
|
+
* and when it began; the file's name says the id too.
|
|
241
|
+
*/
|
|
242
|
+
function piConversations(home) {
|
|
243
|
+
return files(piDir(home))
|
|
244
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
245
|
+
.flatMap((name) => {
|
|
246
|
+
const file = path.join(piDir(home), name);
|
|
247
|
+
const header = piHeader(file);
|
|
248
|
+
const id = typeof header?.id === 'string' ? header.id : piIdOf(name);
|
|
249
|
+
const at = Date.parse(header?.timestamp ?? '') || startedAt(file);
|
|
250
|
+
return at === undefined || id === undefined || id === '' ? [] : [{ id, at, file }];
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A Pi session's first line, when it is the header. */
|
|
255
|
+
function piHeader(file) {
|
|
256
|
+
try {
|
|
257
|
+
const parsed = JSON.parse(firstLine(file) ?? '');
|
|
258
|
+
return parsed?.type === 'session' ? parsed : undefined;
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
231
264
|
/** When a transcript's first line says the conversation was, if it says at all. */
|
|
232
265
|
function said(file) {
|
|
233
266
|
try {
|
package/src/groom.js
CHANGED
|
@@ -44,7 +44,7 @@ import path from 'node:path';
|
|
|
44
44
|
import { botDir, readBot, requireBotsFolder } from './bot.js';
|
|
45
45
|
import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
46
46
|
import { claudeTranscript } from './conversations.js';
|
|
47
|
-
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
47
|
+
import { harnessOf, ownCli, promptFile, shellWord, startPrompt } from './launch.js';
|
|
48
48
|
import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
49
49
|
import { BOT_FATHER, promptPath } from './up.js';
|
|
50
50
|
import { lines } from './usage.js';
|
|
@@ -123,7 +123,7 @@ export function grooming(bots, { at, ask, run } = {}) {
|
|
|
123
123
|
? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
|
|
124
124
|
: [];
|
|
125
125
|
|
|
126
|
-
const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at, run });
|
|
126
|
+
const line = ask === undefined ? undefined : lineFor(ask, { bots, home, found, session, jobs, at, run });
|
|
127
127
|
if (line !== undefined) typeInto(bots, home, session, tab, line);
|
|
128
128
|
|
|
129
129
|
// A move is turning it on at another time, and is typed as one.
|
|
@@ -135,7 +135,7 @@ export function grooming(bots, { at, ask, run } = {}) {
|
|
|
135
135
|
* nothing to ask. Everything that makes the ask impossible refuses here, before
|
|
136
136
|
* Orca is asked for anything.
|
|
137
137
|
*/
|
|
138
|
-
function lineFor(ask, { bots, session, jobs, at, run }) {
|
|
138
|
+
function lineFor(ask, { bots, home, found, session, jobs, at, run }) {
|
|
139
139
|
// Nothing to turn off is an answer, unless a session is there to be told: an
|
|
140
140
|
// `--on` typed a moment ago may still be waiting in its tab.
|
|
141
141
|
if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
|
|
@@ -165,7 +165,8 @@ function lineFor(ask, { bots, session, jobs, at, run }) {
|
|
|
165
165
|
// A move keeps what the runs are on, when every job there says the same.
|
|
166
166
|
const runs = [...new Set(jobs.map((job) => JSON.stringify(job.run ?? null)))];
|
|
167
167
|
const keep = ask === 'move' && runs.length === 1 ? JSON.parse(runs[0]) ?? undefined : undefined;
|
|
168
|
-
|
|
168
|
+
const onRun = run ?? keep;
|
|
169
|
+
return onLine(bots, cronOf(when), jobs, onRun === undefined ? undefined : { ...onRun, task: writeRunTask(bots, ownPrompt(home, found)) });
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/**
|
|
@@ -367,7 +368,7 @@ function runElsewhere(bots, onRun) {
|
|
|
367
368
|
...(onRun.model === undefined ? [] : [`--model ${shellWord(onRun.model)}`]),
|
|
368
369
|
...(onRun.effort === undefined ? [] : [`--effort ${shellWord(onRun.effort)}`]),
|
|
369
370
|
...(onRun.extra_args ?? []).map((arg) => `--extra-arg=${shellWord(arg)}`),
|
|
370
|
-
`--prompt-file ${shellWord(
|
|
371
|
+
`--prompt-file ${shellWord(onRun.task)}`,
|
|
371
372
|
].join(' ');
|
|
372
373
|
return `${markerOf(bots)}: the daily grooming for this fleet, run on ${onRun.harness} as a temporary session of yours `
|
|
373
374
|
+ `that does the work and reports back to you. First, if any of your temporary sessions whose names start with groom- `
|
|
@@ -390,13 +391,29 @@ function readReport(bots) {
|
|
|
390
391
|
+ `Then retire the run with ${retire}, its name in place of ${RUN_NAME}. Schedule nothing then: the job renewed itself when it fired.`;
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
/**
|
|
395
|
+
* The grooming session's own prompt, as the user wrote it, or undefined when it
|
|
396
|
+
* has none: the standing duties a run on Codex carries too (#566). Without the
|
|
397
|
+
* work-dir note the session is launched with, since a run has a work dir of its
|
|
398
|
+
* own. Read when the line is typed, so a prompt changed since reaches the runs
|
|
399
|
+
* at the next `--on`.
|
|
400
|
+
*/
|
|
401
|
+
function ownPrompt(home, session) {
|
|
402
|
+
try {
|
|
403
|
+
return startPrompt(session, { home });
|
|
404
|
+
} catch (error) {
|
|
405
|
+
throw new Error(`${BOT_FATHER}'s ${GROOMING} session's prompt file ${promptFile(session, home)} cannot be read (${error.code ?? error.message}), and each grooming run on Codex carries that prompt. Nothing was typed. Put the file back, or change the session's prompt, then run this again.`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
393
409
|
/**
|
|
394
410
|
* Write the task a Codex run is started with, and answer where: do the daily
|
|
395
|
-
* grooming with the obk-grooming skill,
|
|
396
|
-
*
|
|
397
|
-
*
|
|
411
|
+
* grooming with the obk-grooming skill, and the grooming session's own duties
|
|
412
|
+
* when its prompt has any, send the report to the grooming session by the
|
|
413
|
+
* kit's mail, and stop. It schedules nothing: the schedule is the grooming
|
|
414
|
+
* session's.
|
|
398
415
|
*/
|
|
399
|
-
function writeRunTask(bots) {
|
|
416
|
+
function writeRunTask(bots, own) {
|
|
400
417
|
const real = realHome(bots);
|
|
401
418
|
const kit = shellWord(ownCli());
|
|
402
419
|
const file = path.resolve(promptPath(real, BOT_FATHER, `${GROOMING}-run`));
|
|
@@ -405,6 +422,7 @@ function writeRunTask(bots) {
|
|
|
405
422
|
+ `since the last run's end up to it, counting with ${kit} usage --since <last end> --until <this end>, and write this end down for the next run. `
|
|
406
423
|
+ 'Work out what it cost with obk-finops, and keep each bot\'s profile notes. '
|
|
407
424
|
+ `The kit here is ${kit}: run it wherever a skill says obk. The bots folder is ${real}. `
|
|
425
|
+
+ (own === undefined ? '' : `The ${GROOMING} session's own prompt is this run's too. It follows, word for word as the user wrote it:\n\n${own}\n\n`)
|
|
408
426
|
+ `When the grooming is done, send one short report to ${BOT_FATHER}/${GROOMING} by the kit's mail: `
|
|
409
427
|
+ `ask for the road with ${kit} message to --bots ${shellWord(real)} --to ${BOT_FATHER}/${GROOMING}, then send it with the message send command its answer names. `
|
|
410
428
|
+ 'Then stop: schedule nothing, make no session, and say nothing more.\n';
|
package/src/health.js
CHANGED
|
@@ -18,22 +18,23 @@
|
|
|
18
18
|
// is, the one thing to go and look at, and a sentence naming it — so that
|
|
19
19
|
// somebody who cannot read code knows what was found and where.
|
|
20
20
|
|
|
21
|
-
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
25
|
import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
28
|
+
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
|
|
29
29
|
import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
|
|
30
30
|
import { permissionsTrouble } from './permissions.js';
|
|
31
|
+
import { processesIn } from './processes.js';
|
|
31
32
|
import { TAB_ENV } from './record.js';
|
|
32
33
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
33
34
|
import { settingsInUse } from './settings.js';
|
|
34
35
|
import { skillsTrouble } from './skills.js';
|
|
35
36
|
import { readSources, sourcesDir } from './sources.js';
|
|
36
|
-
import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
37
|
+
import { BOT_FATHER, botsNamed, oldPromptPath, promptPath } from './up.js';
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Everything wrong with the setup at `bots`, in one list: what Orca's own
|
|
@@ -139,8 +140,20 @@ function leftBeside(bots) {
|
|
|
139
140
|
let known = true;
|
|
140
141
|
for (const name of botNames(bots)) {
|
|
141
142
|
try {
|
|
142
|
-
|
|
143
|
+
const home = botDir(bots, name);
|
|
144
|
+
for (const session of readBot(home, name).sessions) {
|
|
143
145
|
owned.add(promptPath(bots, name, session.name));
|
|
146
|
+
// The file a session names as its own prompt, which the kit reads to
|
|
147
|
+
// start it, and the file a link there leads to (#577).
|
|
148
|
+
if (set(session.prompt_file)) {
|
|
149
|
+
const file = promptFile(session, home);
|
|
150
|
+
owned.add(file);
|
|
151
|
+
const real = realpathOf(file);
|
|
152
|
+
if (real !== undefined) owned.add(real);
|
|
153
|
+
}
|
|
154
|
+
// Where a live session's prompt was before #534: nothing reads it once
|
|
155
|
+
// the session starts again, and its retire removes it.
|
|
156
|
+
owned.add(oldPromptPath(bots, name, session.name));
|
|
144
157
|
}
|
|
145
158
|
} catch {
|
|
146
159
|
known = false;
|
|
@@ -148,9 +161,8 @@ function leftBeside(bots) {
|
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
if (known) {
|
|
151
|
-
for (const
|
|
152
|
-
|
|
153
|
-
if (owned.has(at)) continue;
|
|
164
|
+
for (const at of promptFilesIn(`${bots}.prompts`)) {
|
|
165
|
+
if (owned.has(at) || owned.has(realpathOf(at))) continue;
|
|
154
166
|
found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
|
|
155
167
|
}
|
|
156
168
|
}
|
|
@@ -196,9 +208,48 @@ function aboutBot(bots, name, setups, sessions) {
|
|
|
196
208
|
...permissionsTrouble(home, bot).map(said('config')),
|
|
197
209
|
...skillsTrouble(bots, home, bot).map(said('skill')),
|
|
198
210
|
...inOrca(bots, home, bot, setups, sessions),
|
|
211
|
+
...leftRunning(home, bot),
|
|
199
212
|
];
|
|
200
213
|
}
|
|
201
214
|
|
|
215
|
+
/**
|
|
216
|
+
* What still runs in the work dir of a retired session (#537): a retire stops
|
|
217
|
+
* it, so this is one from before that, or one a retire could not stop. The
|
|
218
|
+
* book keeps a retired session's work dir; a temporary session retired before
|
|
219
|
+
* it did worked in `work/<name>`, where `obk temp make` puts it. A work dir a
|
|
220
|
+
* session in bot.yaml still uses is that session's, and not looked at, by its
|
|
221
|
+
* real path, so a link to it is not taken for a retired one (review of PR #543).
|
|
222
|
+
*/
|
|
223
|
+
function leftRunning(home, bot) {
|
|
224
|
+
let book;
|
|
225
|
+
try {
|
|
226
|
+
book = readBook(home);
|
|
227
|
+
} catch {
|
|
228
|
+
// A book that cannot be read is said where the book is read.
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
const real = (dir) => (dir === undefined ? undefined : realpathOf(dir));
|
|
232
|
+
const live = new Set(bot.sessions.map((session) => real(workDirOf(session, home))));
|
|
233
|
+
const dirs = [];
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
for (const entry of Array.isArray(book.retired) ? book.retired : []) {
|
|
236
|
+
const named = typeof entry?.work_dir === 'string' && entry.work_dir !== '' ? entry.work_dir : undefined;
|
|
237
|
+
const dir = workDirOf({ work_dir: named ?? (entry?.temporary === undefined ? undefined : `work/${entry.name}`) }, home);
|
|
238
|
+
// One that is not there holds nothing.
|
|
239
|
+
const at = real(dir);
|
|
240
|
+
if (at === undefined || live.has(at) || seen.has(at)) continue;
|
|
241
|
+
seen.add(at);
|
|
242
|
+
dirs.push({ session: entry.name, dir });
|
|
243
|
+
}
|
|
244
|
+
if (dirs.length === 0) return [];
|
|
245
|
+
|
|
246
|
+
const read = processesIn(dirs);
|
|
247
|
+
if (read.unreadable !== undefined) {
|
|
248
|
+
return [finding('process', bookFile(home), `The kit cannot tell whether anything still runs in the work dirs of ${bot.name}'s retired sessions: ${read.unreadable}.`, bot.name)];
|
|
249
|
+
}
|
|
250
|
+
return read.running.map((one) => finding('process', one.dir, `pid ${one.pid} still runs in the work dir of ${bot.name}/${one.session}, which was retired: ${one.command}. Nothing of the kit's stops it now. If it is not wanted, stop it yourself: kill ${one.pid}`, bot.name));
|
|
251
|
+
}
|
|
252
|
+
|
|
202
253
|
/** A session the kit would refuse to start, in the words `up` refuses it with. */
|
|
203
254
|
function sessionSettings(home, bot) {
|
|
204
255
|
const file = path.join(home, 'bot.yaml');
|
|
@@ -213,7 +264,7 @@ function sessionSettings(home, bot) {
|
|
|
213
264
|
/** What is wrong with the roles the bot's temporary sessions are made in (#465). */
|
|
214
265
|
function rolesTrouble(home, bot) {
|
|
215
266
|
const where = path.join(home, 'bot.yaml');
|
|
216
|
-
// The widest approval they may be made at, the user's yes (ADR
|
|
267
|
+
// The widest approval they may be made at, the user's yes (ADR 0051).
|
|
217
268
|
const level = bot.temp_approval;
|
|
218
269
|
const approval = level === undefined || APPROVALS.includes(level) ? [] : [{
|
|
219
270
|
where,
|
|
@@ -382,7 +433,7 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
382
433
|
|
|
383
434
|
// What the harness has on record in this bot's folder, read once: it is a walk
|
|
384
435
|
// through every conversation on the machine.
|
|
385
|
-
const onRecord = recordIn(real ?? home, bot);
|
|
436
|
+
const onRecord = recordIn(real ?? home, bot, book);
|
|
386
437
|
// A conversation the harness marks as a subagent's was never a session's, so a
|
|
387
438
|
// note that holds one is not a loose end for it (#287). The note stays as it is.
|
|
388
439
|
const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
|
|
@@ -451,16 +502,33 @@ function offTheBook(home, bot, book, onRecord) {
|
|
|
451
502
|
return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
|
|
452
503
|
}
|
|
453
504
|
|
|
454
|
-
/** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
|
|
455
|
-
function recordIn(home, bot) {
|
|
505
|
+
/** Every conversation on record in the bot's folder, from every harness any of its sessions runs on or a retired one may have, as `obk usage` reads them. */
|
|
506
|
+
function recordIn(home, bot, book) {
|
|
456
507
|
const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
|
|
457
508
|
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
509
|
+
// The book does not say which harness a retired session ran on, so any is (#573).
|
|
510
|
+
if (Array.isArray(book.retired) && book.retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
|
|
458
511
|
return [...harnesses]
|
|
459
512
|
.filter((harness) => HARNESSES.includes(harness))
|
|
460
513
|
.flatMap((harness) => transcriptsIn(harness, home));
|
|
461
514
|
}
|
|
462
515
|
|
|
463
516
|
/** What a directory holds, and nothing at all when there is no directory. */
|
|
517
|
+
/**
|
|
518
|
+
* The files in the kit's folder of start prompts: in each bot's own folder
|
|
519
|
+
* there (#534), and flat in the folder itself, where they were before.
|
|
520
|
+
*/
|
|
521
|
+
function promptFilesIn(dir) {
|
|
522
|
+
return namesIn(dir).flatMap((name) => {
|
|
523
|
+
const at = path.join(dir, name);
|
|
524
|
+
try {
|
|
525
|
+
return statSync(at).isDirectory() ? namesIn(at).map((file) => path.join(at, file)) : [at];
|
|
526
|
+
} catch {
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
464
532
|
function namesIn(dir) {
|
|
465
533
|
try {
|
|
466
534
|
return readdirSync(dir);
|
package/src/hooks.js
CHANGED
|
@@ -17,7 +17,11 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
17
17
|
import { leadsOutside } from './bot.js';
|
|
18
18
|
import { ownCli, shellWord } from './launch.js';
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Where each harness reads a project's hooks, inside the bot home. Pi has no
|
|
22
|
+
* hook of the kit's: the kit names a Pi session's id itself, on its launch
|
|
23
|
+
* line (#578).
|
|
24
|
+
*/
|
|
21
25
|
const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
|
|
22
26
|
|
|
23
27
|
/** The hooks file `harness` reads in the bot at `home`. */
|
|
@@ -58,36 +62,7 @@ export const sentCommand = (bots, bot, cli = ownCli()) =>
|
|
|
58
62
|
`${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
59
63
|
|
|
60
64
|
/**
|
|
61
|
-
*
|
|
62
|
-
* about fleet mail the kit sent it that is still unread (#509, ADR 0035).
|
|
63
|
-
* Claude Code only: on Codex a new hook entry puts every session on "Hooks
|
|
64
|
-
* need review", and that waits for #511. It asks Orca only when the kit's hint
|
|
65
|
-
* holds mail for the session, and gives Orca at most ten seconds of its thirty.
|
|
66
|
-
*/
|
|
67
|
-
const MAIL_EVENT = 'Stop';
|
|
68
|
-
const MAIL_TIMEOUT = 30;
|
|
69
|
-
|
|
70
|
-
/** What the mail hook runs, by the kit's own path, as `hookCommand` does. */
|
|
71
|
-
export const mailCommand = (bots, bot, cli = ownCli()) =>
|
|
72
|
-
`${shellWord(cli)} session mail --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Codex's second kit hook: after each command its shell tool ran, outside
|
|
76
|
-
* Codex's sandbox, it decides the mail nudges a send in that command could not
|
|
77
|
-
* (#350, ADR 0034). Codex only, since only Codex runs its commands where `ps`
|
|
78
|
-
* does not start. It types into another session's tab, which takes Orca a few
|
|
79
|
-
* seconds a nudge, so it is given longer than the others.
|
|
80
|
-
*/
|
|
81
|
-
const NUDGE_EVENT = 'PostToolUse';
|
|
82
|
-
const NUDGE_MATCHER = 'Bash';
|
|
83
|
-
const NUDGE_TIMEOUT = 30;
|
|
84
|
-
|
|
85
|
-
/** What the nudge hook runs, by the kit's own path, as `hookCommand` does. */
|
|
86
|
-
export const nudgeCommand = (bots, bot, cli = ownCli()) =>
|
|
87
|
-
`${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Codex's third kit hook: at each turn end, the session's thread is named after
|
|
65
|
+
* Codex's second kit hook: at each turn end, the session's thread is named after
|
|
91
66
|
* its bot and session, so its tab says which it is (#480). Async, so the turn
|
|
92
67
|
* ends without waiting on it: it waits for the session to be idle, up to 30 s,
|
|
93
68
|
* then types and confirms. A naming took 73 s live, each character waiting on
|
|
@@ -111,6 +86,7 @@ export const nameCommand = (bots, bot, cli = ownCli()) =>
|
|
|
111
86
|
* the kit's own entry is written (PRD 6.3).
|
|
112
87
|
*/
|
|
113
88
|
export function installHook(home, harness, { bots, bot }) {
|
|
89
|
+
if (HOOK_FILE[harness] === undefined) return;
|
|
114
90
|
const file = path.join(home, HOOK_FILE[harness]);
|
|
115
91
|
const away = outside(home, file);
|
|
116
92
|
if (away !== undefined) throw new Error(away);
|
|
@@ -123,7 +99,9 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
123
99
|
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
124
100
|
// stays where they put it.
|
|
125
101
|
const started = withKitHook(settings.hooks, file, mine);
|
|
126
|
-
|
|
102
|
+
// The Claude Code mail hook and the Codex nudge hook went with the kit's
|
|
103
|
+
// typed line (#555), so an entry of either is taken out.
|
|
104
|
+
const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, [KIT_MAIL, KIT_NUDGE])) };
|
|
127
105
|
|
|
128
106
|
// Compared as documents, not as text: how the user laid their file out is
|
|
129
107
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -144,6 +122,7 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
144
122
|
* nothing else says so — the book simply stops being true.
|
|
145
123
|
*/
|
|
146
124
|
export function hookTrouble(home, harness, { bots, bot }) {
|
|
125
|
+
if (HOOK_FILE[harness] === undefined) return undefined;
|
|
147
126
|
const file = path.join(home, HOOK_FILE[harness]);
|
|
148
127
|
const stale = `Until it is there, nothing tells the book which conversation this bot's ${harness} sessions are running as, and the book goes stale.`;
|
|
149
128
|
|
|
@@ -171,9 +150,8 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
171
150
|
const program = running ?? ownCli();
|
|
172
151
|
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
173
152
|
// The harness's other hooks are each judged on their own: the session hook
|
|
174
|
-
// may be right while the warning after a native message (ADR 0032)
|
|
175
|
-
//
|
|
176
|
-
// (#480) is missing.
|
|
153
|
+
// may be right while the warning after a native message (ADR 0032) or the
|
|
154
|
+
// naming of a Codex thread (#480) is missing.
|
|
177
155
|
if (isDeepStrictEqual(settings, wanted)) {
|
|
178
156
|
for (const tool of toolHooksOf(harness, bots, bot)) {
|
|
179
157
|
const toolRunning = programIn(settings, tool.pattern);
|
|
@@ -267,24 +245,15 @@ function withKitHook(hooks, file, mine) {
|
|
|
267
245
|
|
|
268
246
|
/** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
|
|
269
247
|
const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
|
|
270
|
-
(harness === 'claude' ? ['sent'
|
|
248
|
+
(harness === 'claude' ? ['sent'] : ['name']).map((kind) => toolHookOf(kind, bots, bot, cli));
|
|
271
249
|
|
|
272
250
|
/**
|
|
273
251
|
* One of the kit's hooks beside its session hook: Claude Code's after a native
|
|
274
|
-
* `SendMessage` (`sent`, ADR 0032), Codex's
|
|
275
|
-
*
|
|
276
|
-
*
|
|
252
|
+
* `SendMessage` (`sent`, ADR 0032), and Codex's at a turn end (`name`, #480).
|
|
253
|
+
* The entry, where it goes, how the kit knows its own, and what is lost
|
|
254
|
+
* without it.
|
|
277
255
|
*/
|
|
278
256
|
function toolHookOf(kind, bots, bot, cli = ownCli()) {
|
|
279
|
-
if (kind === 'mail') {
|
|
280
|
-
return {
|
|
281
|
-
kind,
|
|
282
|
-
event: MAIL_EVENT,
|
|
283
|
-
pattern: KIT_MAIL,
|
|
284
|
-
mine: { type: 'command', command: mailCommand(bots, bot, cli), timeout: MAIL_TIMEOUT },
|
|
285
|
-
missing: 'a Claude session busy when fleet mail came is never told about it at the end of its turn, and nothing is typed into its tab either (#509)',
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
257
|
if (kind === 'name') {
|
|
289
258
|
return {
|
|
290
259
|
kind,
|
|
@@ -294,26 +263,34 @@ function toolHookOf(kind, bots, bot, cli = ownCli()) {
|
|
|
294
263
|
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)",
|
|
295
264
|
};
|
|
296
265
|
}
|
|
297
|
-
if (kind === 'sent') {
|
|
298
|
-
return {
|
|
299
|
-
kind,
|
|
300
|
-
event: SENT_EVENT,
|
|
301
|
-
matcher: SENT_MATCHER,
|
|
302
|
-
pattern: KIT_SENT,
|
|
303
|
-
mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
|
|
304
|
-
missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
266
|
return {
|
|
308
267
|
kind,
|
|
309
|
-
event:
|
|
310
|
-
matcher:
|
|
311
|
-
pattern:
|
|
312
|
-
mine: { type: 'command', command:
|
|
313
|
-
missing:
|
|
268
|
+
event: SENT_EVENT,
|
|
269
|
+
matcher: SENT_MATCHER,
|
|
270
|
+
pattern: KIT_SENT,
|
|
271
|
+
mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
|
|
272
|
+
missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
|
|
314
273
|
};
|
|
315
274
|
}
|
|
316
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The hooks without the kit's entries of these patterns, the hooks it no
|
|
278
|
+
* longer writes, and everything else as the user left it. A group the kit
|
|
279
|
+
* empties goes, and an event left with no groups goes with it.
|
|
280
|
+
*/
|
|
281
|
+
function withoutHooks(hooks, patterns) {
|
|
282
|
+
return Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
283
|
+
if (!Array.isArray(groups)) return [[event, groups]];
|
|
284
|
+
const kept = groups.flatMap((group) => {
|
|
285
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
286
|
+
const entries = group.hooks.filter((hook) => patterns.every((pattern) => programOf(hook?.command, pattern) === undefined));
|
|
287
|
+
if (entries.length === group.hooks.length) return [group];
|
|
288
|
+
return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
|
|
289
|
+
});
|
|
290
|
+
return kept.length === 0 ? [] : [[event, kept]];
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
|
|
317
294
|
/**
|
|
318
295
|
* The hooks with the kit's tool hook in a group of its event matched to its
|
|
319
296
|
* tool (or with no matcher, for one that has none), and everything else as the
|
|
@@ -380,6 +357,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
|
380
357
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
381
358
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
382
359
|
const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
360
|
+
/** The hooks the kit wrote before #555, and takes out. */
|
|
383
361
|
const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
384
362
|
const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
385
363
|
const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
@@ -390,7 +368,7 @@ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --
|
|
|
390
368
|
* (#506). One that runs some other program, even another `obk`, is not.
|
|
391
369
|
*/
|
|
392
370
|
export const runsThisKit = (hook, cli = ownCli()) =>
|
|
393
|
-
hook?.type === 'command' && [KIT_HOOK, KIT_SENT,
|
|
371
|
+
hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
|
|
394
372
|
|
|
395
373
|
/** The program a bot made before the kit named itself by path runs. */
|
|
396
374
|
const BARE = 'obk';
|