@assuredloop/orca-bot-kit 0.1.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/LICENSE +21 -0
- package/README.md +548 -3
- package/SETUP.md +275 -0
- package/package.json +32 -13
- package/rules/evidence.md +15 -0
- package/rules/finishing.md +23 -0
- package/rules/limits.md +18 -0
- package/rules/mail.md +17 -0
- package/rules/profiles.md +9 -0
- package/rules/review.md +14 -0
- package/rules/scope.md +22 -0
- package/rules/simple.md +14 -0
- package/rules/talk.md +13 -0
- package/rules/tests-first.md +17 -0
- package/rules/the-ask.md +19 -0
- package/skills/obk-arch/LICENSES.md +52 -0
- package/skills/obk-arch/NOTICE.md +242 -0
- package/skills/obk-arch/SKILL.md +598 -0
- package/skills/obk-bot-building/LICENSES.md +21 -0
- package/skills/obk-bot-building/NOTICE.md +113 -0
- package/skills/obk-bot-building/SKILL.md +312 -0
- package/skills/obk-debugging/LICENSES.md +67 -0
- package/skills/obk-debugging/NOTICE.md +115 -0
- package/skills/obk-debugging/SKILL.md +434 -0
- package/skills/obk-decision-memo/LICENSES.md +49 -0
- package/skills/obk-decision-memo/NOTICE.md +181 -0
- package/skills/obk-decision-memo/SKILL.md +241 -0
- package/skills/obk-finops/LICENSES.md +19 -0
- package/skills/obk-finops/NOTICE.md +77 -0
- package/skills/obk-finops/SKILL.md +148 -0
- package/skills/obk-fleet-review/LICENSES.md +52 -0
- package/skills/obk-fleet-review/NOTICE.md +104 -0
- package/skills/obk-fleet-review/SKILL.md +256 -0
- package/skills/obk-grilling/LICENSES.md +42 -0
- package/skills/obk-grilling/NOTICE.md +111 -0
- package/skills/obk-grilling/SKILL.md +221 -0
- package/skills/obk-grooming/LICENSES.md +52 -0
- package/skills/obk-grooming/NOTICE.md +111 -0
- package/skills/obk-grooming/SKILL.md +222 -0
- package/skills/obk-handoff/LICENSES.md +45 -0
- package/skills/obk-handoff/NOTICE.md +51 -0
- package/skills/obk-handoff/SKILL.md +119 -0
- package/skills/obk-personal-facilitation/LICENSES.md +53 -0
- package/skills/obk-personal-facilitation/NOTICE.md +62 -0
- package/skills/obk-personal-facilitation/SKILL.md +86 -0
- package/skills/obk-recall/LICENSES.md +42 -0
- package/skills/obk-recall/NOTICE.md +53 -0
- package/skills/obk-recall/SKILL.md +129 -0
- package/skills/obk-researching/LICENSES.md +67 -0
- package/skills/obk-researching/NOTICE.md +170 -0
- package/skills/obk-researching/SKILL.md +269 -0
- package/skills/obk-reviewing/LICENSES.md +66 -0
- package/skills/obk-reviewing/NOTICE.md +58 -0
- package/skills/obk-reviewing/SKILL.md +411 -0
- package/skills/obk-tdd/LICENSES.md +68 -0
- package/skills/obk-tdd/NOTICE.md +54 -0
- package/skills/obk-tdd/SKILL.md +547 -0
- package/skills/obk-tdd/mutation-tools.md +80 -0
- package/skills/obk-teach/LICENSES.md +42 -0
- package/skills/obk-teach/NOTICE.md +65 -0
- package/skills/obk-teach/SKILL.md +106 -0
- package/skills/obk-why/LICENSES.md +42 -0
- package/skills/obk-why/NOTICE.md +74 -0
- package/skills/obk-why/SKILL.md +180 -0
- package/skills/obk-writing/LICENSES.md +272 -0
- package/skills/obk-writing/NOTICE.md +161 -0
- package/skills/obk-writing/SKILL.md +254 -0
- package/src/book.js +268 -0
- package/src/bot.js +451 -0
- package/src/cli.js +1169 -0
- package/src/conversations.js +150 -0
- package/src/groom.js +159 -0
- package/src/health.js +309 -0
- package/src/hooks.js +257 -0
- package/src/init.js +314 -0
- package/src/launch.js +299 -0
- package/src/message.js +402 -0
- package/src/orca.js +362 -0
- package/src/pause.js +63 -0
- package/src/record.js +209 -0
- package/src/restart.js +193 -0
- package/src/retire.js +92 -0
- package/src/roster.js +71 -0
- package/src/rules.js +408 -0
- package/src/skills.js +630 -0
- package/src/sources.js +371 -0
- package/src/up.js +500 -0
- package/src/usage.js +401 -0
- package/bin/okb.js +0 -4
- package/src/index.js +0 -11
package/src/orca.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Every call the kit makes to Orca goes through here (ADR 0011). Orca's CLI
|
|
2
|
+
// changes often, so the kit reads `--json` and never the human text, and keeps
|
|
3
|
+
// the parsing in one place.
|
|
4
|
+
//
|
|
5
|
+
// A word on words: Orca calls every workspace a "worktree", a plain folder
|
|
6
|
+
// included. Nothing the user reads may say that, or they will think a git
|
|
7
|
+
// worktree was made. Say "Orca project". The word belongs only in the selector
|
|
8
|
+
// handed to Orca on the command line.
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
// The Orca that works for a normal user: `/usr/local/bin/orca` is a root-only
|
|
16
|
+
// symlink on this machine (tech notes, section 1). OBK_ORCA overrides it, for a
|
|
17
|
+
// machine that keeps Orca somewhere else.
|
|
18
|
+
const BUILT_IN = '/Applications/Orca.app/Contents/Resources/bin/orca';
|
|
19
|
+
|
|
20
|
+
/** The Orca CLI this run talks to. */
|
|
21
|
+
export const orcaCli = () => process.env.OBK_ORCA || BUILT_IN;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run one Orca command and give back its `result`.
|
|
25
|
+
* Every call asks for `--json`; anything else would be text to guess at.
|
|
26
|
+
*/
|
|
27
|
+
export function orca(args) {
|
|
28
|
+
const answer = ask(args);
|
|
29
|
+
if (answer.ok !== true) {
|
|
30
|
+
// Orca's own words: it knows what went wrong, and the caller is an LLM
|
|
31
|
+
// that can act on them.
|
|
32
|
+
throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
|
|
33
|
+
}
|
|
34
|
+
return answer.result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One Orca command, and the whole envelope back, refusals included. */
|
|
38
|
+
function ask(args) {
|
|
39
|
+
const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8' });
|
|
40
|
+
if (asked.error) {
|
|
41
|
+
throw new Error(`could not run Orca at ${orcaCli()}: ${asked.error.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const answer = parse(asked.stdout);
|
|
45
|
+
if (answer === null || typeof answer !== 'object') {
|
|
46
|
+
throw new Error(`Orca answered ${args.join(' ')} with something that is not JSON.`);
|
|
47
|
+
}
|
|
48
|
+
return answer;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parse(text) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What is wrong with Orca, or undefined when nothing is: it is up and its
|
|
61
|
+
* runtime is reachable, the only state in which any of the calls below mean
|
|
62
|
+
* anything. Asking is cheap, and asking first is what turns "Orca refused
|
|
63
|
+
* terminal create" into a sentence the caller can act on.
|
|
64
|
+
*/
|
|
65
|
+
export function orcaTrouble() {
|
|
66
|
+
const where = `Start Orca and run the command again, or point OBK_ORCA at its CLI if it lives somewhere else.`;
|
|
67
|
+
|
|
68
|
+
const asked = spawnSync(orcaCli(), ['status', '--json'], { encoding: 'utf8' });
|
|
69
|
+
if (asked.error) return `could not run Orca at ${orcaCli()}: ${asked.error.message}. ${where}`;
|
|
70
|
+
|
|
71
|
+
const answer = parse(asked.stdout);
|
|
72
|
+
// Orca knowing what is wrong beats the kit guessing, so its own words go
|
|
73
|
+
// first when it gave any.
|
|
74
|
+
if (answer !== null && typeof answer === 'object' && answer.ok !== true && answer.error?.message) {
|
|
75
|
+
return `Orca is not ready: ${answer.error.message}. ${where}`;
|
|
76
|
+
}
|
|
77
|
+
// `null` is valid JSON with nothing to say, and so is anything else that
|
|
78
|
+
// does not carry a runtime calling itself reachable.
|
|
79
|
+
if (answer?.ok === true && answer.result?.runtime?.reachable === true) return undefined;
|
|
80
|
+
|
|
81
|
+
return `Orca is not answering at ${orcaCli()}. ${where}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every workspace Orca has a record of, folder and git alike. */
|
|
85
|
+
export const projects = () => orca(['project', 'setups']).setups;
|
|
86
|
+
|
|
87
|
+
/** Orca's record of the folder at `home`, or undefined when it knows none. */
|
|
88
|
+
export function findProject(home) {
|
|
89
|
+
return projects().find((setup) => setup.path === home);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Where Orca keeps its own settings: one file per profile, under the user's home. */
|
|
93
|
+
export const profilesDir = () => path.join(homedir(), 'Library', 'Application Support', 'orca', 'profiles');
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Orca's own default launch arguments, per profile it keeps:
|
|
97
|
+
* `{ dir, profiles: [{ file, args }] }`, where `args` maps an agent name to the
|
|
98
|
+
* arguments Orca adds to it, and is undefined for a file that cannot be read.
|
|
99
|
+
*
|
|
100
|
+
* Orca adds these to the agents it launches, relaunches and resumes itself, so
|
|
101
|
+
* they override the approval level a session was started with (PRD 6.5). The
|
|
102
|
+
* kit reads the file and never writes it.
|
|
103
|
+
*
|
|
104
|
+
* A mapping with no entry for an agent is not "no arguments": Orca falls back
|
|
105
|
+
* to its own built-in default, which is the permission bypass itself (tech
|
|
106
|
+
* notes, section 1). So the caller reads a missing entry as one.
|
|
107
|
+
*/
|
|
108
|
+
export function orcaDefaultArgs() {
|
|
109
|
+
const dir = profilesDir();
|
|
110
|
+
|
|
111
|
+
let profiles;
|
|
112
|
+
try {
|
|
113
|
+
profiles = readdirSync(dir);
|
|
114
|
+
} catch {
|
|
115
|
+
return { dir, profiles: [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
dir,
|
|
120
|
+
profiles: profiles
|
|
121
|
+
.map((name) => path.join(dir, name, 'orca-data.json'))
|
|
122
|
+
.filter((file) => existsSync(file))
|
|
123
|
+
.map((file) => ({ file, args: argsIn(file) })),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What one profile file says about the agents' default arguments. */
|
|
128
|
+
function argsIn(file) {
|
|
129
|
+
try {
|
|
130
|
+
const settings = JSON.parse(readFileSync(file, 'utf8'))?.settings?.agentDefaultArgs;
|
|
131
|
+
// Anything that is not a mapping of agents is a mapping with nothing in it,
|
|
132
|
+
// which is Orca falling back to its own defaults for every agent.
|
|
133
|
+
return settings !== null && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Register the folder at `home` and make it a folder workspace called `title`.
|
|
141
|
+
*
|
|
142
|
+
* Two calls, and both are needed: `repo add` registers a folder inside the bots
|
|
143
|
+
* repo as git kind, which Orca gives no workspace at all, so no tab can be
|
|
144
|
+
* created in it. `setup-update` turns it into the folder workspace a bot needs
|
|
145
|
+
* and names the project in one go.
|
|
146
|
+
*/
|
|
147
|
+
export function makeProject(home, title) {
|
|
148
|
+
const setupId = orca(['repo', 'add', '--path', home]).repo.id;
|
|
149
|
+
return asFolderProject(setupId, title);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Make an existing registration a folder workspace called `title`. */
|
|
153
|
+
export function asFolderProject(setupId, title) {
|
|
154
|
+
return orca(['project', 'setup-update', '--setup', setupId, '--kind', 'folder', '--display-name', title])
|
|
155
|
+
.result.setup;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Take a bot's Orca project away: the setup, the project and the repo record,
|
|
160
|
+
* in one call (tech notes, section 1). Only after its tabs are closed: a
|
|
161
|
+
* project taken away first leaves tabs no command line can reach.
|
|
162
|
+
*/
|
|
163
|
+
export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The live tabs of the Orca project at `home`, each under its own tab id.
|
|
167
|
+
*
|
|
168
|
+
* The listing gives a tab whose pane the window has not loaded as orphaned,
|
|
169
|
+
* under `pty:<ptyId>` rather than its id, and a running harness puts a tab the
|
|
170
|
+
* kit opened in that state within seconds. `terminal show` still answers with
|
|
171
|
+
* the real id, so an orphaned entry is given that one: the tab id is the key
|
|
172
|
+
* (PRD 6.2), and this is where Orca is asked for it (tech notes, section 1).
|
|
173
|
+
*/
|
|
174
|
+
export const tabs = (home) => orca(['terminal', 'list', '--worktree', `path:${home}`]).terminals
|
|
175
|
+
.map((tab) => (tab.orphaned === true
|
|
176
|
+
? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle]).terminal.tabId }
|
|
177
|
+
: tab));
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Open a tab in the Orca project at `home`, titled `title`.
|
|
181
|
+
*
|
|
182
|
+
* No `--command`: for a project the kit has just made, `--command` with a
|
|
183
|
+
* harness times out and leaves a tab that never goes live (tech notes). The
|
|
184
|
+
* launch command is typed in afterwards. No `--focus` either: the user is
|
|
185
|
+
* working, and the kit does not take his screen.
|
|
186
|
+
*/
|
|
187
|
+
export const openTab = (home, title) =>
|
|
188
|
+
orca(['terminal', 'create', '--worktree', `path:${home}`, '--title', title]).terminal;
|
|
189
|
+
|
|
190
|
+
/** Set a tab's title. The kit sets titles; it never reads them. */
|
|
191
|
+
export const retitleTab = (handle, title) =>
|
|
192
|
+
orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
|
|
193
|
+
|
|
194
|
+
/**
|
|
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.
|
|
197
|
+
*
|
|
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.
|
|
208
|
+
*/
|
|
209
|
+
export function harnessInTab(handle, timeoutMs) {
|
|
210
|
+
const args = ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)];
|
|
211
|
+
const answer = ask(args);
|
|
212
|
+
|
|
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') {
|
|
216
|
+
throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
|
|
217
|
+
}
|
|
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] };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Close one tab, by the handle Orca issued for it.
|
|
276
|
+
*
|
|
277
|
+
* The only place in the kit that takes anything away, and it takes away one
|
|
278
|
+
* tab: `--terminal <handle> --tab`. Orca's other form, `--worktree <sel> --all`,
|
|
279
|
+
* closes every tab of a project with its layouts and resume records, which are
|
|
280
|
+
* the user's work and not the kit's to end (tech notes, section 1).
|
|
281
|
+
*/
|
|
282
|
+
export const closeTab = (handle) =>
|
|
283
|
+
orca(['terminal', 'close', '--terminal', handle, '--tab']).close;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Type `text` into a tab and press return.
|
|
287
|
+
*
|
|
288
|
+
* Orca sometimes gates a line going into a tab that has an agent in it and
|
|
289
|
+
* answers `agent_prompt_blocked`, carrying a request id and telling the caller
|
|
290
|
+
* to re-issue the exact command with that id and a time to wait for the line to
|
|
291
|
+
* be submitted — and not to retry without it. So that is what this does, once.
|
|
292
|
+
* Seen live on 2026-09-21 in a system test run; not reproduced since, including
|
|
293
|
+
* from a plain shell, long lines and lines sent while the agent was working.
|
|
294
|
+
*
|
|
295
|
+
* The wait is short because the caller is telling a session it has mail, not
|
|
296
|
+
* handing it work: mail that has to wait for the next check is a smaller cost
|
|
297
|
+
* than a command that hangs for half a minute.
|
|
298
|
+
*/
|
|
299
|
+
export function typeIntoTab(handle, text) {
|
|
300
|
+
const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
|
|
301
|
+
const answer = ask(args);
|
|
302
|
+
if (answer.ok === true) return answer.result.send;
|
|
303
|
+
|
|
304
|
+
const again = answer.error?.data?.orchestrationRequestId;
|
|
305
|
+
if (answer.error?.code !== 'agent_prompt_blocked' || typeof again !== 'string') {
|
|
306
|
+
throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
|
|
307
|
+
}
|
|
308
|
+
return orca([...args, '--retry-request', again, '--wait-submit', String(SUBMIT_WAIT_S)]).send;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** How long a re-issued line is given to be submitted before Orca gives up on it. */
|
|
312
|
+
const SUBMIT_WAIT_S = 5;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A mailbox of one session's own: an Orca Run, which is a name and an inbox and
|
|
316
|
+
* nothing else — it schedules nothing and runs nobody. The objective is what a
|
|
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).
|
|
324
|
+
*/
|
|
325
|
+
export const makeMailbox = (objective, handle) =>
|
|
326
|
+
orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Take a turn at reading a mailbox, as the terminal `handle`.
|
|
330
|
+
*
|
|
331
|
+
* Orca fences a Run to one reader: a `check` from a terminal bound elsewhere is
|
|
332
|
+
* refused with `consumer_fenced`, whatever the Run says. So a read binds first,
|
|
333
|
+
* every time — the kit's runs are short and the binding is the last one to have
|
|
334
|
+
* asked, not a lease anybody has to give back. Without a handle it binds this
|
|
335
|
+
* process's own terminal.
|
|
336
|
+
*/
|
|
337
|
+
export const useMailbox = (id, handle) =>
|
|
338
|
+
orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
|
|
339
|
+
|
|
340
|
+
/** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
|
|
341
|
+
export function postMessage({ to, from, subject, body, type = 'status', thread }) {
|
|
342
|
+
const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
|
|
343
|
+
if (from !== undefined) args.push('--from', from);
|
|
344
|
+
if (thread !== undefined) args.push('--thread-id', thread);
|
|
345
|
+
return orca(args).message;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* What is waiting in a mailbox: the oldest batch that has not been
|
|
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.
|
|
353
|
+
*/
|
|
354
|
+
export const readMailbox = (id, { peek = false, handle } = {}) =>
|
|
355
|
+
orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
|
|
356
|
+
|
|
357
|
+
/** Say a batch has been read, so the next check brings the one after it. */
|
|
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
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `obk pause` and `obk unpause`: stop a bot, or one of its sessions, for now,
|
|
2
|
+
// and bring it back as it was.
|
|
3
|
+
//
|
|
4
|
+
// A pause is a mark in the bot's `bot.yaml` and the bot's tabs closed. The
|
|
5
|
+
// mark is what `up` reads to leave it closed, and the book keeps the
|
|
6
|
+
// conversation each session was in, so an unpause is the mark taken off and
|
|
7
|
+
// the ordinary `up`, which resumes it (ADR 0012).
|
|
8
|
+
//
|
|
9
|
+
// The closing is restart's, with restart's rules: only the tabs the book names,
|
|
10
|
+
// one at a time by their own handles, and none at all when one of them holds a
|
|
11
|
+
// conversation the book could not name again.
|
|
12
|
+
|
|
13
|
+
import { realpathSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
import { botDir, markPaused, readBot } from './bot.js';
|
|
16
|
+
import { DAILY_SESSION } from './init.js';
|
|
17
|
+
import { closeTabs, tabsToClose } from './restart.js';
|
|
18
|
+
import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pause the bot `bot`, or its session `session`. Returns
|
|
22
|
+
* `{ bot, session, changed, closed }`: whether the mark was new, and one entry
|
|
23
|
+
* per tab it closed.
|
|
24
|
+
*/
|
|
25
|
+
export async function pauseSessions(bots, { bot, session }) {
|
|
26
|
+
const home = fleetMember(bots, bot, 'pause', session);
|
|
27
|
+
const sessions = sessionsOf(readBot(home, bot), session);
|
|
28
|
+
|
|
29
|
+
// Judged before anything is written or closed, so a refusal leaves both as
|
|
30
|
+
// they were.
|
|
31
|
+
const going = tabsToClose(bots, bot, home, sessions);
|
|
32
|
+
const changed = markPaused(bots, bot, session, true);
|
|
33
|
+
const closed = await closeTabs(home, going, bots, bot);
|
|
34
|
+
return { bot, session, changed, closed };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Take the pause off the bot `bot`, or its session `session`, and bring it up.
|
|
39
|
+
* Returns `{ bot, session, changed, ...what up answered }`.
|
|
40
|
+
*/
|
|
41
|
+
export async function unpauseSessions(bots, { bot, session }) {
|
|
42
|
+
const home = fleetMember(bots, bot, 'unpause', session);
|
|
43
|
+
sessionsOf(readBot(home, bot), session);
|
|
44
|
+
|
|
45
|
+
const changed = markPaused(bots, bot, session, false);
|
|
46
|
+
return { bot, session, changed, ...(await bringUp(bots, { bot, session })) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The bot's home, once it is certain the bot is there and is not Bot Father,
|
|
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).
|
|
53
|
+
*/
|
|
54
|
+
export function fleetMember(bots, bot, verb, session) {
|
|
55
|
+
if (bot === BOT_FATHER && session === undefined) {
|
|
56
|
+
throw new Error(`${BOT_FATHER} runs the fleet, and the kit does not ${verb} it: it is where you ask for everything else.`);
|
|
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
|
+
}
|
|
61
|
+
botsNamed(bots, bot);
|
|
62
|
+
return realpathSync(botDir(bots, bot));
|
|
63
|
+
}
|
package/src/record.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// What a harness says when a session starts, and what the kit does with it.
|
|
2
|
+
//
|
|
3
|
+
// Both harnesses run the kit's hook (src/hooks.js) and hand it the session id
|
|
4
|
+
// they are running under. That is how the book stays the authority for session
|
|
5
|
+
// ids (ADR 0012) without the kit guessing from transcripts.
|
|
6
|
+
//
|
|
7
|
+
// Which session it is comes from the tab: Orca puts the tab id in the
|
|
8
|
+
// environment of everything running in it, and the book says which session owns
|
|
9
|
+
// which tab. Sessions of one bot share a folder, so the folder alone could not
|
|
10
|
+
// tell them apart — the tab can.
|
|
11
|
+
|
|
12
|
+
import { realpathSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, withUnclaimed } from './book.js';
|
|
17
|
+
import { botDir, readBot } from './bot.js';
|
|
18
|
+
import { conversationsIn } from './conversations.js';
|
|
19
|
+
import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
|
|
20
|
+
|
|
21
|
+
/** Where Orca says which tab a program is running in (tech notes, section 1). */
|
|
22
|
+
export const TAB_ENV = 'ORCA_TAB_ID';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The launch line says which shell it ran in, and that is what tells a report
|
|
26
|
+
* from the session itself from a report by something the session started: a tab
|
|
27
|
+
* holds one session — the harness the kit typed in, the shell's own child — and
|
|
28
|
+
* everything started inside the tab inherits the tab's id without being it.
|
|
29
|
+
*/
|
|
30
|
+
export { SHELL_ENV };
|
|
31
|
+
|
|
32
|
+
/** What a harness calls a session the user cleared, when it has a word for it. */
|
|
33
|
+
const CLEARED = 'clear';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write down the session a harness reports, and answer with what the harness is
|
|
37
|
+
* to read back, or undefined when there is nothing to say to it.
|
|
38
|
+
*
|
|
39
|
+
* A cleared session is told its duty again, because the start prompt is the
|
|
40
|
+
* only thing that tells one session of a bot from another (PRD 6.4). A session
|
|
41
|
+
* that started or resumed has already been told, on the line that launched it.
|
|
42
|
+
*
|
|
43
|
+
* Two things say a session was cleared, and either is enough.
|
|
44
|
+
*
|
|
45
|
+
* The harness's own word, when it has one: Claude Code calls a `/clear` a clear.
|
|
46
|
+
* Codex has none to give — it calls its `/new` a startup (proved live, tech
|
|
47
|
+
* notes section 3) — so the second is the book: an id that is not the one this
|
|
48
|
+
* tab had is a conversation the kit did not start, and the only thing that
|
|
49
|
+
* starts one without the kit is the user beginning a new one.
|
|
50
|
+
*
|
|
51
|
+
* Neither can hand the duty over twice. `up` puts it on the launch line only
|
|
52
|
+
* when the book holds no id for the session, so a session with an id in the
|
|
53
|
+
* book was told nothing by the line that started it; and a launch is never a
|
|
54
|
+
* clear.
|
|
55
|
+
*
|
|
56
|
+
* Nothing here knows about Orca beyond the tab id it is handed: the hook runs
|
|
57
|
+
* inside the user's session, and a session is not a place to make calls from.
|
|
58
|
+
*/
|
|
59
|
+
export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
60
|
+
// Without the tab there is no telling which session this is, and a guess
|
|
61
|
+
// would be the one thing this slice must never do.
|
|
62
|
+
if (typeof tabId !== 'string' || tabId === '') return undefined;
|
|
63
|
+
|
|
64
|
+
const id = said?.session_id;
|
|
65
|
+
if (typeof id !== 'string' || id === '') return undefined;
|
|
66
|
+
|
|
67
|
+
const home = realpathSync(botDir(bots, name));
|
|
68
|
+
// A tab no session of this bot owns: Bot Father's ops tab, or somebody else's
|
|
69
|
+
// work. The kit writes nothing down about tabs it does not keep.
|
|
70
|
+
if (sessionAt(readBook(home), tabId) === undefined) return undefined;
|
|
71
|
+
|
|
72
|
+
// And a report from something the session started rather than from the
|
|
73
|
+
// session itself is not this session's news, whatever tab it ran in.
|
|
74
|
+
if (!startedTheSession(shellPid)) return undefined;
|
|
75
|
+
|
|
76
|
+
const bot = readBot(home, name);
|
|
77
|
+
// Asking the harness what it has on record reads a folder of files, so it is
|
|
78
|
+
// done before the lock is taken and never while it is held. It answers about
|
|
79
|
+
// this bot's folder, which every session of the bot shares — so it can say
|
|
80
|
+
// that a conversation nobody claims exists, and never whose it is.
|
|
81
|
+
const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
|
|
82
|
+
|
|
83
|
+
let cleared = false;
|
|
84
|
+
let told;
|
|
85
|
+
|
|
86
|
+
await updateBook(home, (book) => {
|
|
87
|
+
const found = sessionAt(book, tabId);
|
|
88
|
+
// The book is read again under the lock, so a session that went away while
|
|
89
|
+
// this waited is a session this no longer has anything to say about.
|
|
90
|
+
if (found === undefined) return book;
|
|
91
|
+
|
|
92
|
+
const [session, was] = found;
|
|
93
|
+
// The harness's own word for it, or — for the harness that has no word — an
|
|
94
|
+
// id that is not the one this tab had, which is the same thing said twice.
|
|
95
|
+
cleared = said.source === CLEARED || (was.session !== undefined && was.session !== id);
|
|
96
|
+
// A first report with something unclaimed beside it is the third case: the
|
|
97
|
+
// kit cannot tell whether this conversation is the one the launch line spoke
|
|
98
|
+
// to or a later one, and a session left without its duty is the failure this
|
|
99
|
+
// slice exists to prevent, so it is told again.
|
|
100
|
+
const uncertain = was.session === undefined && unclaimed.length > 0;
|
|
101
|
+
told = session;
|
|
102
|
+
|
|
103
|
+
book.sessions[session] = withUnclaimed(rememberSession(was, id, said.source), unclaimed);
|
|
104
|
+
forgetClaimed(book);
|
|
105
|
+
if (uncertain) cleared = true;
|
|
106
|
+
return book;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (!cleared || told === undefined) return undefined;
|
|
110
|
+
|
|
111
|
+
const settings = bot.sessions.find((entry) => entry.name === told);
|
|
112
|
+
if (settings === undefined) return undefined;
|
|
113
|
+
|
|
114
|
+
const duty = startPrompt(settings, { home, workDir: workDirOf(settings, home) });
|
|
115
|
+
if (duty === undefined) return undefined;
|
|
116
|
+
|
|
117
|
+
// The harness's own way of putting text into a session, answered on the event
|
|
118
|
+
// it asked about: what it reads back is added to what the session knows.
|
|
119
|
+
return { hookSpecificOutput: { hookEventName: said.hook_event_name, additionalContext: duty } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The session of this bot that owns `tabId`, with what the book holds for it. */
|
|
123
|
+
const sessionAt = (book, tabId) =>
|
|
124
|
+
Object.entries(book.sessions).find(([, entry]) => entry?.tab === tabId);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The conversations of this bot home that nobody claims, for the session that
|
|
128
|
+
* owns `tabId`: started since the kit put a harness in that tab, not the one
|
|
129
|
+
* reporting now, and not a conversation any session of the bot holds or has
|
|
130
|
+
* held.
|
|
131
|
+
*
|
|
132
|
+
* It says that such a conversation exists. It never says whose it is, and the
|
|
133
|
+
* kit never decides: a bot's sessions share one folder, and so does every
|
|
134
|
+
* harness they start inside themselves, so the folder cannot tell them apart and
|
|
135
|
+
* the time they started cannot either. The review proved both — a `codex exec`
|
|
136
|
+
* child's conversation taken for its parent's, and two sessions reporting in
|
|
137
|
+
* reverse order taking each other's.
|
|
138
|
+
*
|
|
139
|
+
* Nothing either harness writes down ties a conversation that has already ended
|
|
140
|
+
* to the process that had it: Claude Code's registry maps a live pid to the
|
|
141
|
+
* conversation it is having now and keeps no earlier one, and Codex records no
|
|
142
|
+
* pid at all (tech notes, sections 2 and 3). So this is as far as evidence goes,
|
|
143
|
+
* and the rest is for a person or Bot Father to settle.
|
|
144
|
+
*/
|
|
145
|
+
function unclaimedFor(book, home, bot, tabId, id) {
|
|
146
|
+
const found = sessionAt(book, tabId);
|
|
147
|
+
if (found === undefined) return [];
|
|
148
|
+
|
|
149
|
+
const [session, was] = found;
|
|
150
|
+
if (typeof was.launched !== 'string') return [];
|
|
151
|
+
|
|
152
|
+
const settings = bot.sessions.find((entry) => entry.name === session);
|
|
153
|
+
if (settings === undefined) return [];
|
|
154
|
+
|
|
155
|
+
const claimed = sessionIdsIn(book);
|
|
156
|
+
return conversationsIn(harnessOf(settings, bot.harness), home, was.launched)
|
|
157
|
+
.filter((one) => one.id !== id && !claimed.has(one.id))
|
|
158
|
+
.map((one) => one.id);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether the harness that ran this hook is the session's own — the one the kit
|
|
163
|
+
* typed into the tab — rather than a harness started inside the session.
|
|
164
|
+
*
|
|
165
|
+
* Proved live on both harnesses (tech notes, sections 2 and 3): a hook's process
|
|
166
|
+
* is the harness's own child, or the child of the shell the harness ran the hook
|
|
167
|
+
* command through. Either way the harness itself is the process just above, and
|
|
168
|
+
* the session's harness is the child of the shell named in the launch line. A
|
|
169
|
+
* harness started inside the session sits further down a longer chain, under
|
|
170
|
+
* whatever shell the agent ran it from, so it can never answer this.
|
|
171
|
+
*
|
|
172
|
+
* Anything it cannot establish is a no: a report that cannot be shown to be the
|
|
173
|
+
* session's own is not written down.
|
|
174
|
+
*/
|
|
175
|
+
export function startedTheSession(shellPid, pid = process.pid) {
|
|
176
|
+
if (!/^[0-9]+$/.test(String(shellPid ?? ''))) return false;
|
|
177
|
+
const shell = Number(shellPid);
|
|
178
|
+
|
|
179
|
+
const tree = processTree();
|
|
180
|
+
if (tree === undefined) return false;
|
|
181
|
+
|
|
182
|
+
const mine = tree.get(pid);
|
|
183
|
+
const parent = mine === undefined ? undefined : tree.get(mine.ppid);
|
|
184
|
+
if (parent === undefined) return false;
|
|
185
|
+
|
|
186
|
+
if (parent.ppid === shell) return true;
|
|
187
|
+
if (!isShell(parent.command)) return false;
|
|
188
|
+
|
|
189
|
+
const above = tree.get(parent.ppid);
|
|
190
|
+
return above !== undefined && above.ppid === shell;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Every process on the machine, by pid: who its parent is and what it runs. */
|
|
194
|
+
function processTree() {
|
|
195
|
+
const listed = spawnSync('ps', ['-Ao', 'pid=,ppid=,comm='], { encoding: 'utf8' });
|
|
196
|
+
if (listed.error !== undefined || listed.status !== 0) return undefined;
|
|
197
|
+
|
|
198
|
+
const tree = new Map();
|
|
199
|
+
for (const line of listed.stdout.split('\n')) {
|
|
200
|
+
const said = line.trim().match(/^([0-9]+)\s+([0-9]+)\s+(.*)$/);
|
|
201
|
+
if (said !== null) tree.set(Number(said[1]), { ppid: Number(said[2]), command: said[3] });
|
|
202
|
+
}
|
|
203
|
+
return tree.size === 0 ? undefined : tree;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The shells a harness may run a hook command through. */
|
|
207
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
|
|
208
|
+
|
|
209
|
+
const isShell = (command) => SHELLS.has(path.basename(command ?? ''));
|