@assuredloop/orca-bot-kit 0.2.0 → 0.3.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 +15 -12
- package/SETUP.md +7 -2
- package/package.json +1 -1
- package/rules/finishing.md +3 -0
- package/rules/mail.md +3 -3
- package/rules/scope.md +4 -2
- package/rules/tests-first.md +6 -4
- package/skills/obk-arch/LICENSES.md +7 -0
- package/skills/obk-arch/NOTICE.md +113 -5
- package/skills/obk-arch/SKILL.md +221 -10
- package/skills/obk-bot-building/NOTICE.md +2 -2
- package/skills/obk-bot-building/SKILL.md +85 -8
- package/skills/obk-debugging/SKILL.md +30 -15
- package/skills/obk-decision-memo/NOTICE.md +1 -1
- package/skills/obk-finops/NOTICE.md +1 -1
- package/skills/obk-finops/SKILL.md +6 -4
- package/skills/obk-fleet-review/SKILL.md +42 -5
- package/skills/obk-grilling/NOTICE.md +11 -3
- package/skills/obk-grilling/SKILL.md +11 -7
- package/skills/obk-grooming/SKILL.md +5 -2
- package/skills/obk-handoff/NOTICE.md +1 -1
- package/skills/obk-recall/NOTICE.md +1 -1
- package/skills/obk-researching/NOTICE.md +1 -1
- package/skills/obk-researching/SKILL.md +19 -9
- package/skills/obk-reviewing/SKILL.md +4 -1
- package/skills/obk-tdd/SKILL.md +54 -23
- package/skills/obk-teach/NOTICE.md +2 -2
- package/skills/obk-why/NOTICE.md +1 -1
- package/skills/obk-why/SKILL.md +21 -10
- package/src/book.js +2 -2
- package/src/bot.js +1 -1
- package/src/cli.js +59 -20
- package/src/groom.js +5 -3
- package/src/health.js +2 -2
- package/src/hooks.js +73 -18
- package/src/init.js +3 -3
- package/src/launch.js +28 -5
- package/src/message.js +36 -14
- package/src/orca.js +95 -24
- package/src/pause.js +11 -6
- package/src/record.js +1 -1
- package/src/restart.js +6 -5
- package/src/retire.js +2 -2
- package/src/roster.js +1 -1
- package/src/rules.js +2 -2
- package/src/skills.js +7 -6
- package/src/sources.js +4 -3
- package/src/up.js +40 -15
- package/src/usage.js +2 -2
package/src/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The kit's own hook, in the bot's folder and nowhere else (ADR
|
|
1
|
+
// The kit's own hook, in the bot's folder and nowhere else (ADR 0020).
|
|
2
2
|
//
|
|
3
3
|
// Both harnesses read a hooks file out of the folder a session starts in, and
|
|
4
4
|
// every session starts at its bot home, so the bot folder is where the kit's
|
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { isDeepStrictEqual } from 'node:util';
|
|
16
16
|
|
|
17
17
|
import { leadsOutside } from './bot.js';
|
|
18
|
-
import { shellWord } from './launch.js';
|
|
18
|
+
import { ownCli, shellWord } from './launch.js';
|
|
19
19
|
|
|
20
20
|
/** Where each harness reads a project's hooks, inside the bot home. */
|
|
21
21
|
const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
|
|
@@ -31,17 +31,16 @@ const EVENT = 'SessionStart';
|
|
|
31
31
|
/** How long the harness gives the hook before it goes on without it. */
|
|
32
32
|
const TIMEOUT = 10;
|
|
33
33
|
|
|
34
|
-
/** The kit's own hook among whatever else the user keeps in the file. */
|
|
35
|
-
const KIT_COMMAND = 'obk session record';
|
|
36
|
-
|
|
37
34
|
/**
|
|
38
|
-
* What the hook runs
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
35
|
+
* What the hook runs: the CLI that wrote it, by its own path, so the session
|
|
36
|
+
* reports to the kit that set it up and not to whatever `obk` PATH finds in the
|
|
37
|
+
* harness's shell (#220). `|| true` and a quiet stderr because a hook must never
|
|
38
|
+
* disturb the session it fires in (ADR 0020): a kit that is not there, or a book
|
|
39
|
+
* that cannot be written, leaves the session alone and the health check finds
|
|
40
|
+
* the stale book later.
|
|
42
41
|
*/
|
|
43
|
-
export const hookCommand = (bots, bot) =>
|
|
44
|
-
`${
|
|
42
|
+
export const hookCommand = (bots, bot, cli = ownCli()) =>
|
|
43
|
+
`${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
46
|
* Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
|
|
@@ -81,7 +80,7 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
81
80
|
*
|
|
82
81
|
* The same question `installHook` asks, without the writing. It matters more
|
|
83
82
|
* than it looks: the hook is the whole of how the book learns which
|
|
84
|
-
* conversation a session is running as (ADR
|
|
83
|
+
* conversation a session is running as (ADR 0012), and when it is not there
|
|
85
84
|
* nothing else says so — the book simply stops being true.
|
|
86
85
|
*/
|
|
87
86
|
export function hookTrouble(home, harness, { bots, bot }) {
|
|
@@ -95,7 +94,22 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
95
94
|
let settings;
|
|
96
95
|
try {
|
|
97
96
|
settings = readSettings(file);
|
|
98
|
-
|
|
97
|
+
// The hook in the file is judged by what it runs, not by whether this is
|
|
98
|
+
// the install that wrote it. A bot made before the kit named itself by path
|
|
99
|
+
// runs the bare `obk`, and one another install wrote runs that install: both
|
|
100
|
+
// still reach a kit, and the next `obk up` writes this one's. So the line is
|
|
101
|
+
// checked against the program already in it, and only one that names a CLI
|
|
102
|
+
// that is not there any more is wrong — `|| true` would keep that quiet, and
|
|
103
|
+
// the book would stop being true with nothing to say why.
|
|
104
|
+
const running = kitProgramIn(settings);
|
|
105
|
+
if (running !== undefined && running !== BARE && !existsSync(running)) {
|
|
106
|
+
return {
|
|
107
|
+
where: file,
|
|
108
|
+
says: `${file} holds the kit's session hook, but it runs ${running}, which is not there any more. ${stale} obk up puts back one that runs the kit you have.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const program = running ?? ownCli();
|
|
112
|
+
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
99
113
|
} catch (error) {
|
|
100
114
|
return { where: file, says: `${error.message} ${stale}` };
|
|
101
115
|
}
|
|
@@ -110,7 +124,7 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
110
124
|
/**
|
|
111
125
|
* What to say about a hooks file that a link takes out of the bot folder, or
|
|
112
126
|
* undefined when it stays in. The kit writes its hook in the bot folder and
|
|
113
|
-
* nowhere else (ADR
|
|
127
|
+
* nowhere else (ADR 0020); through a link like that it would be writing the
|
|
114
128
|
* user's own settings, so it does not, and says why.
|
|
115
129
|
*/
|
|
116
130
|
function outside(home, file) {
|
|
@@ -169,14 +183,55 @@ function withKitHook(hooks, file, mine) {
|
|
|
169
183
|
/**
|
|
170
184
|
* The kit's own hook entry, wherever it sits and whatever sits beside it: a
|
|
171
185
|
* command of exactly the shape `hookCommand` writes, for any bots folder and
|
|
172
|
-
* bot,
|
|
173
|
-
*
|
|
186
|
+
* bot, run by a kit — the bare `obk` a bot made before #220 still has, this
|
|
187
|
+
* install, or another — so an entry written before the folder moved or the kit
|
|
188
|
+
* changed is still the kit's, and is rewritten in place rather than joined by a
|
|
189
|
+
* second. A line of the user's that only mentions the command, or runs it with
|
|
190
|
+
* a program of their own, is theirs (PRD 6.5).
|
|
174
191
|
*/
|
|
175
|
-
const isKitHook = (hook) =>
|
|
192
|
+
const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
176
193
|
|
|
177
194
|
/** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
|
|
178
195
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
179
|
-
const KIT_HOOK = new RegExp(String.raw
|
|
196
|
+
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
197
|
+
|
|
198
|
+
/** The program a bot made before the kit named itself by path runs. */
|
|
199
|
+
const BARE = 'obk';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether a program is a kit: the bare name, an install's `obk` wherever it
|
|
203
|
+
* lives, or a checkout's `src/cli.js` — the only two ways `ownCli` is reached.
|
|
204
|
+
*/
|
|
205
|
+
const isKitCli = (program) => path.basename(program) === BARE || program.endsWith('/src/cli.js');
|
|
206
|
+
|
|
207
|
+
/** The program a hook command of the kit's runs, unquoted, or undefined for any other command. */
|
|
208
|
+
function kitProgramOf(command) {
|
|
209
|
+
const found = typeof command === 'string' ? KIT_HOOK.exec(command) : null;
|
|
210
|
+
if (found === null) return undefined;
|
|
211
|
+
const program = unquoted(found[1]);
|
|
212
|
+
return isKitCli(program) ? program : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What the kit's hook in these settings runs, as a path or the bare name, or
|
|
217
|
+
* undefined when there is none. Read back out of its shell quoting, so it can be
|
|
218
|
+
* looked for on disk.
|
|
219
|
+
*/
|
|
220
|
+
function kitProgramIn(settings) {
|
|
221
|
+
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
222
|
+
if (!Array.isArray(groups)) continue;
|
|
223
|
+
for (const group of groups) {
|
|
224
|
+
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
225
|
+
const program = kitProgramOf(hook?.command);
|
|
226
|
+
if (program !== undefined) return program;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A word as `shellWord` wrote it, read back to what it stands for. */
|
|
234
|
+
const unquoted = (word) => (word.startsWith("'") ? word.slice(1, -1).replaceAll(`'\\''`, "'") : word);
|
|
180
235
|
|
|
181
236
|
/**
|
|
182
237
|
* What the file says, or nothing when there is no file yet. A file the kit
|
package/src/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Creates the user's bots folder: their own git repo, seeded with the files the
|
|
2
2
|
// kit reads, and Bot Father. The kit puts none of its own code in here — kit
|
|
3
|
-
// skills are linked from the installed package (ADR
|
|
3
|
+
// skills are linked from the installed package (ADR 0014).
|
|
4
4
|
//
|
|
5
5
|
// Seeding only ever adds what is missing. The user edits these files, by hand
|
|
6
6
|
// or through Bot Father, so a re-run must never write over them.
|
|
@@ -15,7 +15,7 @@ import { RECORD, SKILL_DIRS } from './skills.js';
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* What the kit makes on this machine, and what the repo therefore does not
|
|
18
|
-
* carry (ADR
|
|
18
|
+
* carry (ADR 0014, PRD 6.3).
|
|
19
19
|
*
|
|
20
20
|
* A bot's skills are symlinks into wherever the kit and the user's skill
|
|
21
21
|
* sources are installed on the machine that made them, so a clone somewhere
|
|
@@ -126,7 +126,7 @@ const MANAGEMENT_SKILLS = ['obk-bot-building', 'obk-finops', 'obk-fleet-review',
|
|
|
126
126
|
const BOT_FATHER_YAML = 'bots/bot-father/bot.yaml';
|
|
127
127
|
|
|
128
128
|
/** The management session every Bot Father has: the tab you talk to it in. */
|
|
129
|
-
const DAILY_SESSION = 'daily';
|
|
129
|
+
export const DAILY_SESSION = 'daily';
|
|
130
130
|
|
|
131
131
|
const seeds = (harness) => [
|
|
132
132
|
['.gitignore', GITIGNORE],
|
package/src/launch.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What a session is started with: the one line typed into its tab, which brings
|
|
2
2
|
// the harness up and carries the session's start prompt with it.
|
|
3
3
|
//
|
|
4
|
-
// The flag mapping is PRD 6.4 and ADR
|
|
4
|
+
// The flag mapping is PRD 6.4 and ADR 0015, re-checked against the installed
|
|
5
5
|
// CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
|
|
6
6
|
// approval level explicitly, so a user's own global harness defaults cannot
|
|
7
7
|
// leak into a bot. The kit names no model: a session that sets none gets the
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
15
16
|
|
|
16
17
|
/** The approval levels, and what each one is called on each harness. */
|
|
17
18
|
const APPROVAL = {
|
|
@@ -38,7 +39,7 @@ export const bypassFlags = (harness) => APPROVAL[harness]['dangerously-skip'];
|
|
|
38
39
|
|
|
39
40
|
export const HARNESSES = Object.keys(APPROVAL);
|
|
40
41
|
|
|
41
|
-
/** The level a session that names none runs at (ADR
|
|
42
|
+
/** The level a session that names none runs at (ADR 0015). */
|
|
42
43
|
export const DEFAULT_APPROVAL = 'auto';
|
|
43
44
|
|
|
44
45
|
/** A setting the user left out. An empty string is one too: it means the harness's own. */
|
|
@@ -49,7 +50,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* A session's name on Claude Code, which is also the address another Claude
|
|
52
|
-
* session writes to (ADR
|
|
53
|
+
* session writes to (ADR 0018). It goes on the launch line as `-n`, and it is
|
|
53
54
|
* re-applied every time the session is started: a resume keeps the name by
|
|
54
55
|
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
55
56
|
*/
|
|
@@ -161,7 +162,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
161
162
|
|
|
162
163
|
if (harness === 'claude') {
|
|
163
164
|
// The name is the address other Claude sessions write to, so it goes on
|
|
164
|
-
// every launch line, a resume included (ADR
|
|
165
|
+
// every launch line, a resume included (ADR 0018).
|
|
165
166
|
if (address !== undefined) words.push('-n', address);
|
|
166
167
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
167
168
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
|
@@ -174,7 +175,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
174
175
|
if (set(session.effort)) words.push('-c', `model_reasoning_effort=${session.effort}`);
|
|
175
176
|
if (set(session.context)) words.push('-c', `model_context_window=${session.context}`);
|
|
176
177
|
// In Codex's auto mode the sandbox lets it write in the folder it was
|
|
177
|
-
// started in, and nowhere else (ADR
|
|
178
|
+
// started in, and nowhere else (ADR 0015), so a work dir outside the bot
|
|
178
179
|
// home has to be named.
|
|
179
180
|
if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
|
|
180
181
|
}
|
|
@@ -184,6 +185,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
184
185
|
// starts — the shell's child — from anything the session starts later inside
|
|
185
186
|
// the tab. `$$` is the shell's, and it is not quoted for that reason.
|
|
186
187
|
`${SHELL_ENV}=$$`,
|
|
188
|
+
`${CLI_ENV}=${quoted(ownCli())}`,
|
|
187
189
|
...words.map(quoted),
|
|
188
190
|
...extraWords(session.extra_args),
|
|
189
191
|
...resumeWords(harness, resume),
|
|
@@ -274,3 +276,24 @@ const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${wor
|
|
|
274
276
|
|
|
275
277
|
/** The same, for anything else of the kit's that has to build a shell line. */
|
|
276
278
|
export const shellWord = quoted;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The CLI that is running, by the path it was started by: the kit calls itself
|
|
282
|
+
* back with this, never with the bare name `obk`, because the bare name is
|
|
283
|
+
* whatever PATH says, and PATH is not the kit's to decide (#220). A user's
|
|
284
|
+
* install is started through npm's bin link, and the link is kept rather than
|
|
285
|
+
* resolved, so the path survives an upgrade of the package; a system test that
|
|
286
|
+
* runs a checkout's `src/cli.js` gets that checkout. What an environment
|
|
287
|
+
* variable claims is not asked: the kit says who it is from how it was started.
|
|
288
|
+
*/
|
|
289
|
+
export const ownCli = () => (process.argv[1] === undefined
|
|
290
|
+
// Nothing started as a script, as with `node -e`: the kit's own entry file.
|
|
291
|
+
? fileURLToPath(new URL('./cli.js', import.meta.url))
|
|
292
|
+
: path.resolve(process.argv[1]));
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Where the launch line hands the session the CLI that started it, so a bot's
|
|
296
|
+
* own commands can call the same one. A plain variable, because a harness's
|
|
297
|
+
* shell keeps those where it rearranges PATH (#220).
|
|
298
|
+
*/
|
|
299
|
+
export const CLI_ENV = 'OBK_CLI';
|
package/src/message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `obk message`: one session writing to another (PRD 6.9, ADR
|
|
1
|
+
// `obk message`: one session writing to another (PRD 6.9, ADR 0018).
|
|
2
2
|
//
|
|
3
3
|
// Two roads, and the bot never picks. Claude to Claude in the same approval
|
|
4
4
|
// class is the harness's own messaging, which no command line can send for it —
|
|
@@ -20,8 +20,8 @@ import path from 'node:path';
|
|
|
20
20
|
|
|
21
21
|
import { readBook } from './book.js';
|
|
22
22
|
import { botDir, botNames, readBot } from './bot.js';
|
|
23
|
-
import { harnessOf, reachesMail } from './launch.js';
|
|
24
|
-
import { ackMailbox, postMessage, readMailbox, tabs,
|
|
23
|
+
import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
|
|
24
|
+
import { ackMailbox, harnessInTab, postMessage, readMailbox, tabs, typeIntoTab, useMailbox } from './orca.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* How much of a message travels as itself. Above this it is written to a file
|
|
@@ -147,7 +147,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
|
|
|
147
147
|
return {
|
|
148
148
|
...answer,
|
|
149
149
|
sent: false,
|
|
150
|
-
trouble: `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first:
|
|
150
|
+
trouble: `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${shellWord(from.bot)}`,
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -192,10 +192,15 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
|
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
// Read as the session's own tab, wherever this check was typed. Binding the
|
|
196
|
+
// tab that asked would hand it the session's mailbox, and Orca's notice for
|
|
197
|
+
// every message after (issue #228). A session with no live tab is read as
|
|
198
|
+
// this process's own terminal, as it always was.
|
|
199
|
+
const handle = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
|
|
200
|
+
useMailbox(who.mailbox, handle);
|
|
201
|
+
const found = readMailbox(who.mailbox, { peek, handle });
|
|
197
202
|
const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
|
|
198
|
-
if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId);
|
|
203
|
+
if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
|
|
199
204
|
|
|
200
205
|
return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
|
|
201
206
|
}
|
|
@@ -295,9 +300,10 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
|
|
|
295
300
|
*
|
|
296
301
|
* Nothing in the mailbox reaches a running harness by itself, and a typed line
|
|
297
302
|
* is taken as the next turn by a busy session rather than cutting into the one
|
|
298
|
-
* it is having. A tab with no
|
|
303
|
+
* it is having. A tab with no harness in it is not typed into at all — there is
|
|
299
304
|
* nobody there to read it, and the message waits in the mailbox until the
|
|
300
|
-
* session is up.
|
|
305
|
+
* session is up. Nor is one the kit cannot tell about. A tab the book does not
|
|
306
|
+
* hold is never typed into on any road.
|
|
301
307
|
*
|
|
302
308
|
* Nor is a tab with something on screen waiting to be answered. A line typed
|
|
303
309
|
* into one of those is not a message: it is an answer to whatever question is
|
|
@@ -318,13 +324,29 @@ function nudge(to, from, subject) {
|
|
|
318
324
|
const live = tabs(to.home).find((tab) => tab.tabId === to.tab);
|
|
319
325
|
if (live === undefined) return { nudged: false };
|
|
320
326
|
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
|
|
327
|
+
const seen = harnessInTab(live.handle, LOOK_MS);
|
|
328
|
+
if (seen.blockedReason !== undefined) return { nudged: false, blocked: seen.blockedReason };
|
|
329
|
+
// The shell in front is a tab with no harness, whatever a stale
|
|
330
|
+
// `agentIdentity` says.
|
|
331
|
+
if (seen.front === 'shell') return { nudged: false };
|
|
332
|
+
// Not knowing is not a reason to type: a line that lands in a shell is run
|
|
333
|
+
// there, with the sender's subject in it. The program in front has to be
|
|
334
|
+
// the agent Orca names. With no name it may be a harness a few seconds
|
|
335
|
+
// into its launch; under another name it may be a pager started after the
|
|
336
|
+
// harness quit, under an identity Orca has not let go of (tech notes,
|
|
337
|
+
// section 1). A harness run through a wrapper such as `node` lands here
|
|
338
|
+
// too, until #261.
|
|
339
|
+
if (seen.front === undefined || seen.agent === undefined || seen.command !== seen.agent) {
|
|
340
|
+
const why = seen.front === undefined
|
|
341
|
+
? seen.unreadable
|
|
342
|
+
: `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
|
|
343
|
+
return { nudged: false, nudgeTrouble: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
|
|
344
|
+
}
|
|
345
|
+
// A busy harness is typed into: it takes the line as its next turn.
|
|
324
346
|
|
|
325
347
|
typeIntoTab(
|
|
326
348
|
live.handle,
|
|
327
|
-
`Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with
|
|
349
|
+
`Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
|
|
328
350
|
);
|
|
329
351
|
return { nudged: true };
|
|
330
352
|
} catch (error) {
|
|
@@ -377,4 +399,4 @@ function whyNotReachable(bot, session, harness, held) {
|
|
|
377
399
|
}
|
|
378
400
|
|
|
379
401
|
const notUpYet = (who) =>
|
|
380
|
-
`${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one:
|
|
402
|
+
`${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${shellWord(ownCli())} up --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`;
|
package/src/orca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Every call the kit makes to Orca goes through here (ADR
|
|
1
|
+
// Every call the kit makes to Orca goes through here (ADR 0011). Orca's CLI
|
|
2
2
|
// changes often, so the kit reads `--json` and never the human text, and keeps
|
|
3
3
|
// the parsing in one place.
|
|
4
4
|
//
|
|
@@ -192,25 +192,83 @@ export const retitleTab = (handle, title) =>
|
|
|
192
192
|
orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
|
|
193
193
|
|
|
194
194
|
/**
|
|
195
|
-
*
|
|
195
|
+
* What can be learned about the harness in a tab: what Orca says, and who is
|
|
196
|
+
* in front of the tab's terminal.
|
|
196
197
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
198
|
+
* Orca's answers alone do not tell a harness from a shell (tech notes, section
|
|
199
|
+
* 1, #232). `tui-idle` refuses a busy harness with `timeout` just as it refuses
|
|
200
|
+
* a shell, and a shell Codex quit to answers `ok`. `agentIdentity` comes late
|
|
201
|
+
* and can outlast the harness by minutes. So the answer the caller acts on is
|
|
202
|
+
* `front`: `program` when something other than the tab's shell holds its
|
|
203
|
+
* terminal, with `command` its name, `shell` when the shell does, and
|
|
204
|
+
* undefined when that cannot be read, with `unreadable` saying why. `answered` (an `ok` from `tui-idle`) and
|
|
205
|
+
* `agent` are Orca's own hints, for a caller that has nothing better.
|
|
206
|
+
* `blockedReason` is Orca saying something on screen wants answering, which is
|
|
207
|
+
* for the caller to deal with, not the kit.
|
|
203
208
|
*/
|
|
204
|
-
export function
|
|
209
|
+
export function harnessInTab(handle, timeoutMs) {
|
|
205
210
|
const args = ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)];
|
|
206
211
|
const answer = ask(args);
|
|
207
212
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
// Out of time means nothing went idle, busy or absent alike. That is an
|
|
214
|
+
// answer, not a breakdown.
|
|
215
|
+
if (answer.ok !== true && answer.error?.code !== 'timeout') {
|
|
211
216
|
throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
|
|
212
217
|
}
|
|
213
|
-
|
|
218
|
+
|
|
219
|
+
const shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
|
|
220
|
+
const agent = typeof shown?.agentIdentity === 'string' && shown.agentIdentity !== '' ? shown.agentIdentity : undefined;
|
|
221
|
+
return {
|
|
222
|
+
answered: answer.ok === true,
|
|
223
|
+
blockedReason: answer.result?.wait?.blockedReason,
|
|
224
|
+
agent,
|
|
225
|
+
...frontOf(shown?.ptyId),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
230
|
+
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Who holds the terminal of the pane `ptyId`: `{ front: 'shell' }`,
|
|
234
|
+
* `{ front: 'program', command }` with the name of the process leading the
|
|
235
|
+
* group in front, or `{ unreadable: <why> }`.
|
|
236
|
+
*
|
|
237
|
+
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
238
|
+
* gives that pid's terminal's foreground process group (ADR 0011).
|
|
239
|
+
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
240
|
+
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
241
|
+
* `diagnostics memory` is a diagnostics command and may change, so everything
|
|
242
|
+
* here ends in "cannot tell" rather than in an error or a guess.
|
|
243
|
+
*/
|
|
244
|
+
function frontOf(ptyId) {
|
|
245
|
+
let pane;
|
|
246
|
+
try {
|
|
247
|
+
pane = orca(['diagnostics', 'memory']).worktrees
|
|
248
|
+
.flatMap((worktree) => worktree.sessions ?? [])
|
|
249
|
+
.find((session) => session.sessionId === ptyId)?.pid;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return { unreadable: `Orca would not give the pane's pid: ${error.message}` };
|
|
252
|
+
}
|
|
253
|
+
if (!Number.isInteger(pane) || pane <= 0) return { unreadable: 'Orca gave no pid for its pane' };
|
|
254
|
+
|
|
255
|
+
const own = psLine(pane);
|
|
256
|
+
if (own === undefined) return { unreadable: `ps could not read its pane, pid ${pane}` };
|
|
257
|
+
if (own.tpgid === pane) return { front: 'shell' };
|
|
258
|
+
|
|
259
|
+
const front = psLine(own.tpgid);
|
|
260
|
+
if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
|
|
261
|
+
const shell = front.ppid === pane && path.basename(own.comm) === 'login';
|
|
262
|
+
return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
|
|
266
|
+
function psLine(pid) {
|
|
267
|
+
const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8' });
|
|
268
|
+
if (asked.error || asked.status !== 0) return undefined;
|
|
269
|
+
const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
|
|
270
|
+
if (line === null) return undefined;
|
|
271
|
+
return { ppid: Number(line[2]), tpgid: Number(line[3]), comm: line[4] };
|
|
214
272
|
}
|
|
215
273
|
|
|
216
274
|
/**
|
|
@@ -257,19 +315,27 @@ const SUBMIT_WAIT_S = 5;
|
|
|
257
315
|
* A mailbox of one session's own: an Orca Run, which is a name and an inbox and
|
|
258
316
|
* nothing else — it schedules nothing and runs nobody. The objective is what a
|
|
259
317
|
* person sees in `orca orchestration run-list`, so it says whose it is.
|
|
318
|
+
*
|
|
319
|
+
* Bound to `handle`, the session's own tab, from the moment it is made. Orca
|
|
320
|
+
* tells a Run's coordinator terminal about its mail and nobody else, and binds
|
|
321
|
+
* the caller when no `--from` is given: the tab that ran `obk up` would be told
|
|
322
|
+
* about every session it brought up, and would lose its own Run's binding, since
|
|
323
|
+
* one terminal holds one Run (tech notes, section 1).
|
|
260
324
|
*/
|
|
261
|
-
export const makeMailbox = (objective) =>
|
|
262
|
-
orca(['orchestration', 'run-create', '--objective', `obk ${objective}
|
|
325
|
+
export const makeMailbox = (objective, handle) =>
|
|
326
|
+
orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
|
|
263
327
|
|
|
264
328
|
/**
|
|
265
|
-
* Take
|
|
329
|
+
* Take a turn at reading a mailbox, as the terminal `handle`.
|
|
266
330
|
*
|
|
267
|
-
* Orca fences a Run to one reader: a `check` from a
|
|
331
|
+
* Orca fences a Run to one reader: a `check` from a terminal bound elsewhere is
|
|
268
332
|
* refused with `consumer_fenced`, whatever the Run says. So a read binds first,
|
|
269
333
|
* every time — the kit's runs are short and the binding is the last one to have
|
|
270
|
-
* asked, not a lease anybody has to give back.
|
|
334
|
+
* asked, not a lease anybody has to give back. Without a handle it binds this
|
|
335
|
+
* process's own terminal.
|
|
271
336
|
*/
|
|
272
|
-
export const useMailbox = (id) =>
|
|
337
|
+
export const useMailbox = (id, handle) =>
|
|
338
|
+
orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
|
|
273
339
|
|
|
274
340
|
/** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
|
|
275
341
|
export function postMessage({ to, from, subject, body, type = 'status', thread }) {
|
|
@@ -281,11 +347,16 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
|
|
|
281
347
|
|
|
282
348
|
/**
|
|
283
349
|
* What is waiting in a mailbox: the oldest batch that has not been
|
|
284
|
-
* acknowledged, or, peeking, whatever is unread without touching it.
|
|
350
|
+
* acknowledged, or, peeking, whatever is unread without touching it. Read as
|
|
351
|
+
* the terminal `handle` when one is given, which is how a read works from a tab
|
|
352
|
+
* bound to another Run; otherwise as this process's own terminal.
|
|
285
353
|
*/
|
|
286
|
-
export const readMailbox = (id, { peek = false } = {}) =>
|
|
287
|
-
orca(['orchestration', 'check', '--run', id, ...(peek ? ['--peek'] : [])]);
|
|
354
|
+
export const readMailbox = (id, { peek = false, handle } = {}) =>
|
|
355
|
+
orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
|
|
288
356
|
|
|
289
357
|
/** Say a batch has been read, so the next check brings the one after it. */
|
|
290
|
-
export const ackMailbox = (id, delivery) =>
|
|
291
|
-
orca(['orchestration', 'check', '--run', id, '--ack', delivery]);
|
|
358
|
+
export const ackMailbox = (id, delivery, handle) =>
|
|
359
|
+
orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
|
|
360
|
+
|
|
361
|
+
/** The reader of a `check`: the terminal named, or this process's own. */
|
|
362
|
+
const as = (handle) => (handle === undefined ? [] : ['--terminal', handle]);
|
package/src/pause.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// A pause is a mark in the bot's `bot.yaml` and the bot's tabs closed. The
|
|
5
5
|
// mark is what `up` reads to leave it closed, and the book keeps the
|
|
6
6
|
// conversation each session was in, so an unpause is the mark taken off and
|
|
7
|
-
// the ordinary `up`, which resumes it (ADR
|
|
7
|
+
// the ordinary `up`, which resumes it (ADR 0012).
|
|
8
8
|
//
|
|
9
9
|
// The closing is restart's, with restart's rules: only the tabs the book names,
|
|
10
10
|
// one at a time by their own handles, and none at all when one of them holds a
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { realpathSync } from 'node:fs';
|
|
14
14
|
|
|
15
15
|
import { botDir, markPaused, readBot } from './bot.js';
|
|
16
|
+
import { DAILY_SESSION } from './init.js';
|
|
16
17
|
import { closeTabs, tabsToClose } from './restart.js';
|
|
17
18
|
import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
|
|
18
19
|
|
|
@@ -22,7 +23,7 @@ import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
|
|
|
22
23
|
* per tab it closed.
|
|
23
24
|
*/
|
|
24
25
|
export async function pauseSessions(bots, { bot, session }) {
|
|
25
|
-
const home = fleetMember(bots, bot, 'pause');
|
|
26
|
+
const home = fleetMember(bots, bot, 'pause', session);
|
|
26
27
|
const sessions = sessionsOf(readBot(home, bot), session);
|
|
27
28
|
|
|
28
29
|
// Judged before anything is written or closed, so a refusal leaves both as
|
|
@@ -38,7 +39,7 @@ export async function pauseSessions(bots, { bot, session }) {
|
|
|
38
39
|
* Returns `{ bot, session, changed, ...what up answered }`.
|
|
39
40
|
*/
|
|
40
41
|
export async function unpauseSessions(bots, { bot, session }) {
|
|
41
|
-
const home = fleetMember(bots, bot, 'unpause');
|
|
42
|
+
const home = fleetMember(bots, bot, 'unpause', session);
|
|
42
43
|
sessionsOf(readBot(home, bot), session);
|
|
43
44
|
|
|
44
45
|
const changed = markPaused(bots, bot, session, false);
|
|
@@ -47,12 +48,16 @@ export async function unpauseSessions(bots, { bot, session }) {
|
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* The bot's home, once it is certain the bot is there and is not Bot Father,
|
|
50
|
-
*
|
|
51
|
+
* or its management session: that is where the user asks to bring anything
|
|
52
|
+
* back. Bot Father's other sessions are like any bot's (#230).
|
|
51
53
|
*/
|
|
52
|
-
export function fleetMember(bots, bot, verb) {
|
|
53
|
-
if (bot === BOT_FATHER) {
|
|
54
|
+
export function fleetMember(bots, bot, verb, session) {
|
|
55
|
+
if (bot === BOT_FATHER && session === undefined) {
|
|
54
56
|
throw new Error(`${BOT_FATHER} runs the fleet, and the kit does not ${verb} it: it is where you ask for everything else.`);
|
|
55
57
|
}
|
|
58
|
+
if (bot === BOT_FATHER && session === DAILY_SESSION) {
|
|
59
|
+
throw new Error(`${DAILY_SESSION} is ${BOT_FATHER}'s management session, and the kit does not ${verb} it: it is where you ask for everything else.`);
|
|
60
|
+
}
|
|
56
61
|
botsNamed(bots, bot);
|
|
57
62
|
return realpathSync(botDir(bots, bot));
|
|
58
63
|
}
|
package/src/record.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Both harnesses run the kit's hook (src/hooks.js) and hand it the session id
|
|
4
4
|
// they are running under. That is how the book stays the authority for session
|
|
5
|
-
// ids (ADR
|
|
5
|
+
// ids (ADR 0012) without the kit guessing from transcripts.
|
|
6
6
|
//
|
|
7
7
|
// Which session it is comes from the tab: Orca puts the tab id in the
|
|
8
8
|
// environment of everything running in it, and the book says which session owns
|
package/src/restart.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// avoided, not banned, and one happens when the user asks for it).
|
|
6
6
|
//
|
|
7
7
|
// When they do ask, the session has to come back as itself. The book holds the
|
|
8
|
-
// conversation each session is running (ADR
|
|
8
|
+
// conversation each session is running (ADR 0012), so a restart is: close the
|
|
9
9
|
// tab the book names, then let `up` open a new one and resume that
|
|
10
10
|
// conversation. Nothing else about it is new.
|
|
11
11
|
//
|
|
@@ -31,6 +31,7 @@ import { setTimeout as pause } from 'node:timers/promises';
|
|
|
31
31
|
|
|
32
32
|
import { bookFile, readBook } from './book.js';
|
|
33
33
|
import { botDir, readBot } from './bot.js';
|
|
34
|
+
import { ownCli, shellWord } from './launch.js';
|
|
34
35
|
import { closeTab, findProject, tabs } from './orca.js';
|
|
35
36
|
import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
|
|
36
37
|
|
|
@@ -49,10 +50,10 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
|
|
|
49
50
|
// A paused bot or session has no tab to close and is not to be opened, so
|
|
50
51
|
// there is nothing here to restart.
|
|
51
52
|
if (known.paused === true) {
|
|
52
|
-
throw new Error(`${name} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with
|
|
53
|
+
throw new Error(`${name} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with ${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${name}.`);
|
|
53
54
|
}
|
|
54
55
|
if (onlySession !== undefined && sessions[0].paused === true) {
|
|
55
|
-
throw new Error(`${name} ${onlySession} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with
|
|
56
|
+
throw new Error(`${name} ${onlySession} is paused, so there is nothing of it to restart and nothing was closed. Bring it back with ${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${name} --session ${onlySession}.`);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
// And the same preparations, made now rather than left to `up`, which makes
|
|
@@ -158,7 +159,7 @@ async function gone(home, closed, bots, bot) {
|
|
|
158
159
|
|
|
159
160
|
if (Date.now() >= until) {
|
|
160
161
|
const one = left.length === 1;
|
|
161
|
-
throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book:
|
|
162
|
+
throw new Error(`Orca answered the close for ${one ? 'this tab' : 'these tabs'} and is still listing ${one ? 'it' : 'them'} ${SETTLED_MS / 1000} seconds later: ${left.join(', ')}. Nothing was opened in ${one ? 'its' : 'their'} place, because a tab that is on the way out is not a tab to start a harness in. The conversations are in the book: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings them back once Orca has caught up.`);
|
|
162
163
|
}
|
|
163
164
|
await pause(ASK_MS);
|
|
164
165
|
}
|
|
@@ -188,5 +189,5 @@ function alsoClosed(why, closed, bots, bot) {
|
|
|
188
189
|
if (closed.length === 0) return why;
|
|
189
190
|
|
|
190
191
|
const one = closed.length === 1;
|
|
191
|
-
return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it and nothing was opened in ${one ? 'its' : 'their'} place:
|
|
192
|
+
return `${why} ${closed.map((tab) => tab.name).join(', ')} ${one ? 'was' : 'were'} closed before it and nothing was opened in ${one ? 'its' : 'their'} place: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} brings ${one ? 'it' : 'them'} back with ${one ? 'its' : 'their'} conversation.`;
|
|
192
193
|
}
|
package/src/retire.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Closing a tab here ends the conversation in it on purpose, so unlike a
|
|
16
16
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
17
|
-
// The mailbox Runs stay: Orca has no way to remove one (ADR
|
|
17
|
+
// The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
|
|
18
18
|
|
|
19
19
|
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
@@ -34,7 +34,7 @@ export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
|
34
34
|
* Retire the session `session` of the bot `bot`. Returns `{ bot, session, closed }`.
|
|
35
35
|
*/
|
|
36
36
|
export async function retireSession(bots, { bot, session }) {
|
|
37
|
-
const home = fleetMember(bots, bot, 'retire');
|
|
37
|
+
const home = fleetMember(bots, bot, 'retire', session);
|
|
38
38
|
const sessions = sessionsOf(readBot(home, bot), session);
|
|
39
39
|
|
|
40
40
|
const closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot);
|