@assuredloop/orca-bot-kit 0.20.0 → 0.21.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 +6 -0
- package/package.json +1 -1
- package/rules/temporary.md +7 -8
- package/src/cli.js +59 -15
- package/src/hooks.js +89 -1
- package/src/retire.js +45 -13
- package/src/sent.js +111 -0
- package/src/temp.js +44 -20
package/README.md
CHANGED
|
@@ -344,6 +344,12 @@ settings ([ADR 0022](docs/adr/0022-kit-hooks-live-in-the-bot-folder.md)). Your o
|
|
|
344
344
|
settings in those files are kept. Codex asks you to trust a hooks file the first
|
|
345
345
|
time it sees one; answer its question in the tab.
|
|
346
346
|
|
|
347
|
+
A Claude bot gets one more hook there. Claude Code's own messaging reaches every
|
|
348
|
+
Claude session on the machine, so after a session sends a native message to an
|
|
349
|
+
address that is none of your bots folder's sessions, the hook tells it so, and
|
|
350
|
+
names the address `obk message to` would have given. The message goes either
|
|
351
|
+
way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
|
|
352
|
+
|
|
347
353
|
So: kill a tab, or reboot, and `obk up` brings the session back with the
|
|
348
354
|
conversation it was having, rather than starting a new one. Clear a session —
|
|
349
355
|
`/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
|
package/package.json
CHANGED
package/rules/temporary.md
CHANGED
|
@@ -4,18 +4,17 @@ title: Temporary sessions of your own
|
|
|
4
4
|
applies: all
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
For a piece of work that wants a helper, a
|
|
8
|
-
|
|
7
|
+
For a piece of work that wants a helper, a session may make a temporary
|
|
8
|
+
session of its own bot, without asking Bot Father:
|
|
9
9
|
`"${OBK_CLI:-obk}" temp make --bots <bots> --name <name> --prompt <the task>`,
|
|
10
10
|
run in your own tab. It takes your settings unless you say otherwise, and gets
|
|
11
|
-
a work dir, a tab and a mailbox.
|
|
11
|
+
a work dir, a tab and a mailbox. A temporary session may make one of its own,
|
|
12
|
+
one at a time; one made by a temporary session makes none.
|
|
12
13
|
|
|
13
14
|
A new tab can stop on a first-run screen. Answer it yourself, from the table
|
|
14
15
|
the command's output points to.
|
|
15
16
|
|
|
16
|
-
Retire it when its work is done:
|
|
17
|
+
Retire it when its work is done, and what it made goes with it:
|
|
17
18
|
`"${OBK_CLI:-obk}" temp retire --bots <bots> --name <name>`. Before you make
|
|
18
|
-
another, first retire any of yours whose work is done.
|
|
19
|
-
|
|
20
|
-
You manage only the temporary sessions you made; a temporary session makes
|
|
21
|
-
none of its own. Every other session is Bot Father's to make, change or retire.
|
|
19
|
+
another, first retire any of yours whose work is done. You manage only the
|
|
20
|
+
temporary sessions you made; every other session is Bot Father's.
|
package/src/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { pauseSessions, unpauseSessions } from './pause.js';
|
|
|
24
24
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
25
25
|
import { restartSessions } from './restart.js';
|
|
26
26
|
import { retireBot, retireSession } from './retire.js';
|
|
27
|
+
import { sentWarning } from './sent.js';
|
|
27
28
|
import { readRoster } from './roster.js';
|
|
28
29
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
29
30
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
@@ -90,28 +91,34 @@ Usage:
|
|
|
90
91
|
session with the conversation it was having.
|
|
91
92
|
obk retire --bots <path> --bot <bot> [--session <name>]
|
|
92
93
|
End a session: close its tab and take it off the
|
|
93
|
-
bot, keeping its conversations in the book
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
bot, keeping its conversations in the book, and
|
|
95
|
+
first do the same for the temporary sessions it
|
|
96
|
+
made, and theirs. Or end a bot: close its tabs,
|
|
97
|
+
remove its Orca project and move its folder to
|
|
98
|
+
retired/. It will not retire a bot whose Orca
|
|
99
|
+
project holds a tab your book does not name, and
|
|
100
|
+
moves the folder only once Orca no longer lists
|
|
101
|
+
the project.
|
|
99
102
|
obk temp make --bots <path> --name <session>
|
|
100
103
|
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
101
104
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
102
105
|
[--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
|
|
103
|
-
Run in a
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
Run in a session's own tab: make a temporary
|
|
107
|
+
session of that session's bot for a piece of work,
|
|
108
|
+
and bring it up. A temporary session makes one at
|
|
109
|
+
a time, and one it made makes none. It takes your
|
|
110
|
+
harness, model, effort, context and approval unless
|
|
111
|
+
you say otherwise; on another harness than yours
|
|
112
|
+
it takes only your approval. It works in
|
|
113
|
+
work/<session>, and has the task as its start
|
|
114
|
+
prompt. The book records it as temporary, made by
|
|
115
|
+
you.
|
|
111
116
|
obk temp retire --bots <path> --name <session>
|
|
112
117
|
Run in the maker's own tab: retire a temporary
|
|
113
118
|
session it made, as obk retire does. It refuses a
|
|
114
119
|
long-lived session, or one another session made.
|
|
120
|
+
Like obk retire, it first retires the temporary
|
|
121
|
+
sessions that session made.
|
|
115
122
|
obk temp trust-hooks --bots <path> --name <session>
|
|
116
123
|
Run in the maker's own tab: answer the hooks review
|
|
117
124
|
of a Codex temporary session it made with "Trust
|
|
@@ -230,6 +237,11 @@ Usage:
|
|
|
230
237
|
For the kit's own hook, not for typing: it reads
|
|
231
238
|
what the harness says about a session starting on
|
|
232
239
|
standard input and writes it into the book.
|
|
240
|
+
obk session sent --bots <path> --bot <bot>
|
|
241
|
+
For the kit's own hook, not for typing: after a
|
|
242
|
+
Claude session's native message, it warns the
|
|
243
|
+
session when the address was none of this bots
|
|
244
|
+
folder's own. It never stops a message.
|
|
233
245
|
obk session mailbox --bots <path> --bot <bot> --session <name>
|
|
234
246
|
For the kit's own launch line, not for typing: run
|
|
235
247
|
in the session's own tab, it gives the session its
|
|
@@ -269,6 +281,7 @@ const COMMANDS = {
|
|
|
269
281
|
'message send': ['bots', 'to', 'subject'],
|
|
270
282
|
'message check': ['bots'],
|
|
271
283
|
'session record': ['bots', 'bot'],
|
|
284
|
+
'session sent': ['bots', 'bot'],
|
|
272
285
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
273
286
|
'temp make': ['bots', 'name'],
|
|
274
287
|
'temp retire': ['bots', 'name'],
|
|
@@ -394,6 +407,10 @@ async function run(argv) {
|
|
|
394
407
|
throw new Error(`--harness is ${HARNESSES.join(' or ')}, and got: ${values.harness}`);
|
|
395
408
|
}
|
|
396
409
|
|
|
410
|
+
// The send hook is quiet whatever it is given, a bots folder that is not
|
|
411
|
+
// there included, so it goes before anything that can complain (ADR 0032).
|
|
412
|
+
if (command === SENT) return sent(path.resolve(values.bots));
|
|
413
|
+
|
|
397
414
|
// One fleet, one identity, whatever spelling of its path was given (#164).
|
|
398
415
|
const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
|
|
399
416
|
if (command === RECORD) return record(bots, values.bot);
|
|
@@ -457,8 +474,26 @@ const ANSWER_IT = [
|
|
|
457
474
|
' Anything you do not recognise gets no keypress: take it to the user.',
|
|
458
475
|
];
|
|
459
476
|
|
|
460
|
-
/** The
|
|
477
|
+
/** The commands a harness runs rather than a person: the kit's hooks. */
|
|
461
478
|
const RECORD = 'session record';
|
|
479
|
+
const SENT = 'session sent';
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* What the kit's send hook says after a native message: a warning the session
|
|
483
|
+
* reads, as Claude Code's `additionalContext`, or nothing. Never a decision,
|
|
484
|
+
* and never anything but exit 0 (ADR 0032).
|
|
485
|
+
*/
|
|
486
|
+
function sent(bots) {
|
|
487
|
+
try {
|
|
488
|
+
const warning = sentWarning(bots, JSON.parse(readFileSync(0, 'utf8')));
|
|
489
|
+
if (warning !== undefined) {
|
|
490
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: warning } })}\n`);
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
// Nothing: a hook does not disturb the session it runs in.
|
|
494
|
+
}
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
462
497
|
|
|
463
498
|
/**
|
|
464
499
|
* What the kit's hook does with what the harness told it, and what it answers.
|
|
@@ -571,6 +606,7 @@ const commands = {
|
|
|
571
606
|
return {
|
|
572
607
|
answer: { bots, ...retired },
|
|
573
608
|
lines: [
|
|
609
|
+
...withLines(retired.retiredWith, retired.session),
|
|
574
610
|
...closedLines(retired.closed),
|
|
575
611
|
`retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
576
612
|
...leftLines(retired.promptsLeft),
|
|
@@ -633,6 +669,7 @@ const commands = {
|
|
|
633
669
|
return {
|
|
634
670
|
answer: { bots, ...retired },
|
|
635
671
|
lines: [
|
|
672
|
+
...withLines(retired.retiredWith, retired.session),
|
|
636
673
|
...closedLines(retired.closed),
|
|
637
674
|
`retired ${retired.bot} ${retired.session}, a temporary session of ${retired.maker}'s: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
638
675
|
...leftLines(retired.promptsLeft),
|
|
@@ -1106,6 +1143,13 @@ function settingsOf(values) {
|
|
|
1106
1143
|
/** The tabs a command closed, one line each. */
|
|
1107
1144
|
const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
1108
1145
|
|
|
1146
|
+
/** The temporary sessions a retire took along with the one it named, each as it went (#464). */
|
|
1147
|
+
const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
|
|
1148
|
+
...closedLines(gone.closed),
|
|
1149
|
+
`retired ${gone.bot} ${gone.session}, a temporary session of ${gone.maker}'s, along with ${session}: off ${path.join('bots', gone.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
1150
|
+
...leftLines(gone.promptsLeft),
|
|
1151
|
+
]);
|
|
1152
|
+
|
|
1109
1153
|
/** What a retire could not remove, and how to: nothing reads these files now (#393). */
|
|
1110
1154
|
const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
|
|
1111
1155
|
`left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
|
package/src/hooks.js
CHANGED
|
@@ -42,6 +42,18 @@ const TIMEOUT = 10;
|
|
|
42
42
|
export const hookCommand = (bots, bot, cli = ownCli()) =>
|
|
43
43
|
`${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Claude Code's second kit hook: after a native `SendMessage`, a warning when
|
|
47
|
+
* it went outside the bots folder (ADR 0032). Claude Code only, since Codex has
|
|
48
|
+
* no native messaging.
|
|
49
|
+
*/
|
|
50
|
+
const SENT_EVENT = 'PostToolUse';
|
|
51
|
+
const SENT_MATCHER = 'SendMessage';
|
|
52
|
+
|
|
53
|
+
/** What the send hook runs, by the kit's own path, as `hookCommand` does. */
|
|
54
|
+
export const sentCommand = (bots, bot, cli = ownCli()) =>
|
|
55
|
+
`${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
56
|
+
|
|
45
57
|
/**
|
|
46
58
|
* Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
|
|
47
59
|
* Returns the file it wrote, relative to the bot home, or undefined when the
|
|
@@ -63,7 +75,9 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
63
75
|
// path is corrected rather than doubled, an entry under an event the kit no
|
|
64
76
|
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
65
77
|
// stays where they put it.
|
|
66
|
-
const
|
|
78
|
+
const started = withKitHook(settings.hooks, file, mine);
|
|
79
|
+
const sentHook = { type: 'command', command: sentCommand(bots, bot), timeout: TIMEOUT };
|
|
80
|
+
const wanted = { ...settings, hooks: harness === 'claude' ? withSentHook(started, sentHook) : started };
|
|
67
81
|
|
|
68
82
|
// Compared as documents, not as text: how the user laid their file out is
|
|
69
83
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -110,6 +124,27 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
110
124
|
}
|
|
111
125
|
const program = running ?? ownCli();
|
|
112
126
|
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
127
|
+
// The send hook is judged on its own: the session hook may be right while
|
|
128
|
+
// the warning after a native message is missing (ADR 0032).
|
|
129
|
+
if (harness === 'claude' && isDeepStrictEqual(settings, wanted)) {
|
|
130
|
+
const sentRunning = sentProgramIn(settings);
|
|
131
|
+
// A send hook that runs a kit no longer there is as quiet as a missing
|
|
132
|
+
// one: `|| true` hides it, and no warning is ever given (#451 review).
|
|
133
|
+
if (sentRunning !== undefined && sentRunning !== BARE && !existsSync(sentRunning)) {
|
|
134
|
+
return {
|
|
135
|
+
where: file,
|
|
136
|
+
says: `${file} holds the kit's PostToolUse hook for SendMessage, but it runs ${sentRunning}, which is not there any more, so a session is never warned when its native message goes outside this bots folder (ADR 0032). obk up puts back one that runs the kit you have.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const sentProgram = sentRunning ?? program;
|
|
140
|
+
const withSent = { ...settings, hooks: withSentHook(settings.hooks, { type: 'command', command: sentCommand(bots, bot, sentProgram), timeout: TIMEOUT }) };
|
|
141
|
+
if (!isDeepStrictEqual(settings, withSent)) {
|
|
142
|
+
return {
|
|
143
|
+
where: file,
|
|
144
|
+
says: `${file} does not hold the kit's PostToolUse hook for SendMessage, which warns a session when its native message went outside this bots folder (ADR 0032). obk up puts it back.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
113
148
|
} catch (error) {
|
|
114
149
|
return { where: file, says: `${error.message} ${stale}` };
|
|
115
150
|
}
|
|
@@ -180,6 +215,58 @@ function withKitHook(hooks, file, mine) {
|
|
|
180
215
|
return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
|
|
181
216
|
}
|
|
182
217
|
|
|
218
|
+
/**
|
|
219
|
+
* The hooks with the kit's send hook in a `PostToolUse` group matched to
|
|
220
|
+
* `SendMessage`, and everything else as the user left it, as `withKitHook`
|
|
221
|
+
* keeps the session hook: one entry of the kit's, written in place where it
|
|
222
|
+
* already sits in such a group, taken out of anywhere else, and added in a
|
|
223
|
+
* group of its own when there is none. A group the kit empties goes.
|
|
224
|
+
*/
|
|
225
|
+
function withSentHook(hooks, mine) {
|
|
226
|
+
let placed = false;
|
|
227
|
+
const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
228
|
+
if (!Array.isArray(groups)) return [[event, groups]];
|
|
229
|
+
const kept = groups.flatMap((group) => {
|
|
230
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
231
|
+
const entries = group.hooks.flatMap((hook) => {
|
|
232
|
+
if (!isSentHook(hook)) return [hook];
|
|
233
|
+
if (event !== SENT_EVENT || group.matcher !== SENT_MATCHER || placed) return [];
|
|
234
|
+
placed = true;
|
|
235
|
+
return [mine];
|
|
236
|
+
});
|
|
237
|
+
return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
|
|
238
|
+
});
|
|
239
|
+
return kept.length === 0 ? [] : [[event, kept]];
|
|
240
|
+
}));
|
|
241
|
+
if (placed) return events;
|
|
242
|
+
return { ...events, [SENT_EVENT]: [...(events[SENT_EVENT] ?? []), { matcher: SENT_MATCHER, hooks: [mine] }] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
/** Whether a hook entry is the kit's send hook, run by a kit, for any bots folder and bot. */
|
|
247
|
+
const isSentHook = (hook) => sentProgramOf(hook?.command) !== undefined;
|
|
248
|
+
|
|
249
|
+
function sentProgramOf(command) {
|
|
250
|
+
const found = typeof command === 'string' ? KIT_SENT.exec(command) : null;
|
|
251
|
+
if (found === null) return undefined;
|
|
252
|
+
const program = unquoted(found[1]);
|
|
253
|
+
return isKitCli(program) ? program : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** What the kit's send hook in these settings runs, or undefined when there is none. */
|
|
257
|
+
function sentProgramIn(settings) {
|
|
258
|
+
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
259
|
+
if (!Array.isArray(groups)) continue;
|
|
260
|
+
for (const group of groups) {
|
|
261
|
+
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
262
|
+
const program = sentProgramOf(hook?.command);
|
|
263
|
+
if (program !== undefined) return program;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
183
270
|
/**
|
|
184
271
|
* The kit's own hook entry, wherever it sits and whatever sits beside it: a
|
|
185
272
|
* command of exactly the shape `hookCommand` writes, for any bots folder and
|
|
@@ -194,6 +281,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
|
194
281
|
/** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
|
|
195
282
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
196
283
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
284
|
+
const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
197
285
|
|
|
198
286
|
/** The program a bot made before the kit named itself by path runs. */
|
|
199
287
|
const BARE = 'obk';
|
package/src/retire.js
CHANGED
|
@@ -25,6 +25,7 @@ import path from 'node:path';
|
|
|
25
25
|
import { readBook, tabIdsIn, updateBook } from './book.js';
|
|
26
26
|
import { botDir, dropSession, readBot } from './bot.js';
|
|
27
27
|
import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
|
|
28
|
+
import { shellWord } from './launch.js';
|
|
28
29
|
import { fleetMember } from './pause.js';
|
|
29
30
|
import { closeTabs, commandLine, tabsToClose } from './restart.js';
|
|
30
31
|
import { unlinkSkills } from './skills.js';
|
|
@@ -34,26 +35,57 @@ import { promptPath, sessionsOf } from './up.js';
|
|
|
34
35
|
export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
|
-
* Retire the session `session` of the bot `bot
|
|
38
|
-
*
|
|
38
|
+
* Retire the session `session` of the bot `bot`, and first the temporary
|
|
39
|
+
* sessions it made, and theirs (#464, ADR 0033). Returns `{ bot, session,
|
|
40
|
+
* closed, retiredWith }`, and `promptsLeft` when its prompt file could not be
|
|
41
|
+
* removed. `retiredWith` holds one `{ bot, session, maker, closed }` for each
|
|
42
|
+
* session that went with it, deepest first, with its own `promptsLeft`.
|
|
39
43
|
*/
|
|
40
44
|
export async function retireSession(bots, { bot, session }) {
|
|
41
45
|
const home = fleetMember(bots, bot, 'retire', session);
|
|
42
|
-
const
|
|
46
|
+
const known = readBot(home, bot);
|
|
47
|
+
const sessions = sessionsOf(known, session);
|
|
43
48
|
|
|
44
|
-
const
|
|
45
|
-
|
|
49
|
+
const retiredWith = [];
|
|
50
|
+
const book = readBook(home);
|
|
51
|
+
const made = known.sessions.filter((one) => book.sessions[one.name]?.temporary?.maker === session);
|
|
52
|
+
let closed;
|
|
53
|
+
try {
|
|
54
|
+
for (const { name } of made) {
|
|
55
|
+
const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name });
|
|
56
|
+
retiredWith.push(...theirs, { ...gone, maker: session });
|
|
57
|
+
}
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
|
|
60
|
+
dropSession(bots, bot, session);
|
|
61
|
+
|
|
62
|
+
const at = new Date().toISOString();
|
|
63
|
+
await updateBook(home, (book) => {
|
|
64
|
+
const entry = book.sessions[session];
|
|
65
|
+
if (entry === undefined) return;
|
|
66
|
+
delete book.sessions[session];
|
|
67
|
+
book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// What already went stays gone, and is named: a retire run again finds
|
|
71
|
+
// nothing of it left to name (#464).
|
|
72
|
+
if (retiredWith.length > 0) error.message = `${error.message} ${goneBefore(session, retiredWith)}`;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
54
75
|
const left = removePrompts([promptPath(bots, bot, session)]);
|
|
55
76
|
|
|
56
|
-
return { bot, session, closed, ...left };
|
|
77
|
+
return { bot, session, closed, retiredWith, ...left };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The sessions retired along with `session` before its own retire failed, in
|
|
82
|
+
* words, with any prompt file of theirs left and how to remove it.
|
|
83
|
+
*/
|
|
84
|
+
function goneBefore(session, retiredWith) {
|
|
85
|
+
const names = retiredWith.map((gone) => `${gone.bot}/${gone.session}, a temporary session of ${gone.maker}'s`);
|
|
86
|
+
const left = retiredWith.flatMap((gone) => gone.promptsLeft ?? [])
|
|
87
|
+
.map(({ file, reason }) => ` ${file} could not be removed (${reason}), and nothing reads it. Remove it with rm ${shellWord(file)}`);
|
|
88
|
+
return `Retired along with ${session} before that, and still retired: ${names.join('; ')}.${left.join('')}`;
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
/**
|
package/src/sent.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// `obk session sent`: what the kit's PostToolUse hook says after a session's
|
|
2
|
+
// native message (#451, ADR 0032).
|
|
3
|
+
//
|
|
4
|
+
// Claude Code's own messaging reaches every Claude session on the machine, not
|
|
5
|
+
// only the sessions of one bots folder. After a send to another session, this
|
|
6
|
+
// looks at where it went: to a session address none of the fleet's books name,
|
|
7
|
+
// the session is told so, with the road the kit would have given it. The send
|
|
8
|
+
// has gone either way. Nothing here decides anything about it, and anything it
|
|
9
|
+
// cannot read is silence (ADR 0022: a hook never disturbs the session).
|
|
10
|
+
//
|
|
11
|
+
// It reads the send from its fields, never from its wording. Seen on Claude
|
|
12
|
+
// Code 2.1.283: a send to an in-process teammate answers with a `routing`
|
|
13
|
+
// object, and one to another session without it; the input's `to` names the
|
|
14
|
+
// target, as a session's name with an optional ` [xxxxxx]` reference, or as a
|
|
15
|
+
// `uds:` socket path when answering a message, which no book can map.
|
|
16
|
+
|
|
17
|
+
import { readBook } from './book.js';
|
|
18
|
+
import { botDir, botNames } from './bot.js';
|
|
19
|
+
import { isAddressOf, ownCli, shellWord } from './launch.js';
|
|
20
|
+
import { lookUp } from './message.js';
|
|
21
|
+
import { TAB_ENV } from './record.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The warning for a send the hook was told about, or undefined when there is
|
|
25
|
+
* nothing to say. `said` is what Claude Code handed the hook.
|
|
26
|
+
*/
|
|
27
|
+
export function sentWarning(bots, said) {
|
|
28
|
+
if (said?.tool_name !== 'SendMessage') return undefined;
|
|
29
|
+
const to = said.tool_input?.to;
|
|
30
|
+
if (typeof to !== 'string') return undefined;
|
|
31
|
+
const result = resultOf(said);
|
|
32
|
+
// A teammate in the same session, or a send that did not go: not this.
|
|
33
|
+
if (result === undefined || result.success !== true || 'routing' in result) return undefined;
|
|
34
|
+
if (/^(uds|bridge):/.test(to)) return undefined;
|
|
35
|
+
|
|
36
|
+
const name = to.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
|
37
|
+
if (name === '') return undefined;
|
|
38
|
+
const fleet = addressesIn(bots);
|
|
39
|
+
if (fleet === undefined || fleet.all.has(name)) return undefined;
|
|
40
|
+
|
|
41
|
+
const meant = fleet.byName.get(name.split('.').slice(0, 2).join('.'));
|
|
42
|
+
const ask = (to) => `\`${shellWord(ownCli())} message to --bots ${shellWord(bots)} --to ${to}\``;
|
|
43
|
+
const road = meant === undefined
|
|
44
|
+
? `${ask('<bot>/<session>')} gives the road to a session of this fleet.`
|
|
45
|
+
: roadTo(bots, meant, ask(`${meant.bot}/${meant.session}`));
|
|
46
|
+
return `That message went to ${name}, which is not one of the sessions of your bots folder, ${bots}: Claude Code's messaging reaches every session on this machine. ${road} Check before you send to it again.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The road to the session the sender probably meant, as `obk message to` would
|
|
51
|
+
* answer it from the sender's own tab: native to its address only between
|
|
52
|
+
* sessions of one approval class, the kit's mailbox otherwise (PRD 6.9). Where
|
|
53
|
+
* the sender cannot be told by its tab, the session's address and the question
|
|
54
|
+
* to ask, without claiming the address is the answer (#451 review).
|
|
55
|
+
*/
|
|
56
|
+
function roadTo(bots, meant, ask) {
|
|
57
|
+
let found;
|
|
58
|
+
try {
|
|
59
|
+
found = lookUp(bots, { to: `${meant.bot}/${meant.session}`, tab: process.env[TAB_ENV] });
|
|
60
|
+
} catch {
|
|
61
|
+
found = undefined;
|
|
62
|
+
}
|
|
63
|
+
const who = `${meant.bot}/${meant.session}`;
|
|
64
|
+
if (found?.transport === 'native' && typeof found.address === 'string') {
|
|
65
|
+
return `If you meant ${who}, ${ask} gives the road from your session: write to ${found.address} with your own messaging.`;
|
|
66
|
+
}
|
|
67
|
+
if (found?.transport === 'orca' && typeof found.address === 'string') {
|
|
68
|
+
return `If you meant ${who}, ${ask} gives the road from your session: its mailbox, ${found.address}, by \`${shellWord(ownCli())} message send\`, not your own messaging.`;
|
|
69
|
+
}
|
|
70
|
+
return `If you meant ${who}, its session address is ${meant.address}; ${ask} says which road to take from your session.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What the send answered, as an object: Claude Code hands it over as one, or as its JSON. */
|
|
74
|
+
function resultOf(said) {
|
|
75
|
+
const raw = said.tool_output ?? said.tool_response;
|
|
76
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) return raw;
|
|
77
|
+
if (typeof raw !== 'string') return undefined;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every session address the fleet's books name, retired sessions' included, and
|
|
88
|
+
* each live one by `<bot>.<session>`: `{ all, byName }`. Undefined when a book
|
|
89
|
+
* cannot be read, since an address missing from it would be warned about wrongly.
|
|
90
|
+
*/
|
|
91
|
+
function addressesIn(bots) {
|
|
92
|
+
const all = new Set();
|
|
93
|
+
const byName = new Map();
|
|
94
|
+
try {
|
|
95
|
+
for (const bot of botNames(bots)) {
|
|
96
|
+
const book = readBook(botDir(bots, bot));
|
|
97
|
+
for (const [session, entry] of Object.entries(book.sessions ?? {})) {
|
|
98
|
+
const address = entry?.address;
|
|
99
|
+
if (typeof address !== 'string') continue;
|
|
100
|
+
all.add(address);
|
|
101
|
+
if (isAddressOf(bot, session, address)) byName.set(`${bot}.${session}`, { bot, session, address });
|
|
102
|
+
}
|
|
103
|
+
for (const entry of Array.isArray(book.retired) ? book.retired : []) {
|
|
104
|
+
if (typeof entry?.address === 'string') all.add(entry.address);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return { all, byName };
|
|
111
|
+
}
|
package/src/temp.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Any long-lived session can make a temporary session of its own bot for a
|
|
6
6
|
// piece of work, and retire it when the work is done, without Bot Father, who
|
|
7
|
-
// stays the manager of long-lived sessions. A temporary session
|
|
7
|
+
// stays the manager of long-lived sessions. A temporary session can make one
|
|
8
|
+
// of its own, one level deep and one at a time (#464, ADR 0033). A temporary session is an ordinary
|
|
8
9
|
// session in every other way: in bot.yaml, brought up with a tab and a mailbox,
|
|
9
10
|
// its conversations kept in the book. What makes it temporary is one field of
|
|
10
11
|
// its book entry, `temporary: { maker, made }`, and the book is the one place
|
|
@@ -44,9 +45,9 @@ const INHERITED = ['model', 'effort', 'context', 'approval'];
|
|
|
44
45
|
*/
|
|
45
46
|
export async function makeTemp(bots, { tab, ...given }) {
|
|
46
47
|
const caller = callerIn(bots, tab, 'make');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// Checked here, before anything is written, and again under the book's lock
|
|
49
|
+
// below, where two makes at once cannot both pass it.
|
|
50
|
+
refuseNested(bots, caller, readBook(caller.home));
|
|
50
51
|
// The name is a folder under work/ and a file beside the bots folder, so it
|
|
51
52
|
// is held to the rule a bot's name is: one plain name, never a path.
|
|
52
53
|
if (!NAME.test(given.name)) {
|
|
@@ -77,38 +78,44 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
77
78
|
settings.work_dir = `work/${given.name}`;
|
|
78
79
|
|
|
79
80
|
const written = bot.sessions.some((session) => session.name === given.name) ? undefined : writePromptFirst(bots, caller, settings);
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
added = addSession(bots, caller.bot, settings);
|
|
85
|
-
} catch (error) {
|
|
86
|
-
if (written !== undefined) rmSync(written, { force: true });
|
|
87
|
-
throw error;
|
|
88
|
-
}
|
|
81
|
+
// bot.yaml and the book are written under the book's lock, so that a second
|
|
82
|
+
// make from the same maker waits, and then sees this one (#464). addSession
|
|
83
|
+
// refuses a name the bot already has, and a setting that will not work,
|
|
84
|
+
// before it writes anything.
|
|
89
85
|
const made = new Date().toISOString();
|
|
86
|
+
let locked = false;
|
|
87
|
+
let added;
|
|
90
88
|
try {
|
|
91
89
|
await updateBook(caller.home, (book) => {
|
|
90
|
+
locked = true;
|
|
91
|
+
refuseNested(bots, caller, book);
|
|
92
|
+
added = addSession(bots, caller.bot, settings);
|
|
92
93
|
book.sessions[given.name] = { ...book.sessions[given.name], temporary: { maker: caller.session, made } };
|
|
93
94
|
});
|
|
94
95
|
} catch (error) {
|
|
96
|
+
// A refusal under the lock has written nothing but the prompt file.
|
|
97
|
+
const refused = locked && added === undefined;
|
|
98
|
+
const failed = `${caller.bot}/${given.name} could not be recorded in the book as temporary (${error.message})`;
|
|
95
99
|
// A session in bot.yaml that the book does not call temporary would be a
|
|
96
100
|
// long-lived one to everything that reads it, its maker's retire included,
|
|
97
101
|
// so it is taken back off rather than left that way.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
if (added !== undefined) {
|
|
103
|
+
try {
|
|
104
|
+
dropSession(bots, caller.bot, given.name);
|
|
105
|
+
} catch (undo) {
|
|
106
|
+
throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name}`);
|
|
107
|
+
}
|
|
103
108
|
}
|
|
104
109
|
// Off bot.yaml, nothing is made; a prompt file that stays is only a file,
|
|
105
110
|
// and nothing reads it (#393).
|
|
111
|
+
const why = refused ? error.message.replace(/ ?Nothing was made\.$/, '') : `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
|
|
106
112
|
try {
|
|
107
113
|
if (written !== undefined) rmSync(written, { force: true });
|
|
108
114
|
} catch (left) {
|
|
109
|
-
throw new Error(`${
|
|
115
|
+
throw new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
|
|
110
116
|
}
|
|
111
|
-
|
|
117
|
+
if (refused) throw error;
|
|
118
|
+
throw new Error(`${why} Nothing was made. Run this again once the book can be written.`);
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
let up;
|
|
@@ -121,6 +128,23 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
121
128
|
return { bot: caller.bot, session: given.name, maker: caller.session, settings: added.session, up };
|
|
122
129
|
}
|
|
123
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Refuse a make by a temporary session that may not make one now, against
|
|
133
|
+
* `book`: one made by a temporary session makes none, and a temporary session
|
|
134
|
+
* has one of its own open at a time (#464, ADR 0033).
|
|
135
|
+
*/
|
|
136
|
+
function refuseNested(bots, caller, book) {
|
|
137
|
+
if (caller.temporary === undefined) return;
|
|
138
|
+
const maker = caller.temporary.maker;
|
|
139
|
+
if (book.sessions[maker]?.temporary !== undefined) {
|
|
140
|
+
throw new Error(`${caller.bot}/${caller.session} is a temporary session ${maker} made, and ${maker} is temporary itself, so it makes none of its own: temporary sessions go one level deep. Nothing was made.`);
|
|
141
|
+
}
|
|
142
|
+
const open = Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.maker === caller.session);
|
|
143
|
+
if (open.length > 0) {
|
|
144
|
+
throw new Error(`${caller.bot}/${caller.session} is a temporary session, and it already has ${open.join(', ')} open; a temporary session has one of its own at a time. Retire it first with ${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${open[0]} Nothing was made.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
124
148
|
/**
|
|
125
149
|
* Write the start prompt's file where bringing the session up will write it,
|
|
126
150
|
* when its prompt goes to the harness out of a file at all, and say where. A
|