@assuredloop/orca-bot-kit 0.21.0 → 0.23.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 +26 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +3 -2
- package/src/book.js +2 -1
- package/src/bot.js +78 -1
- package/src/clear.js +455 -0
- package/src/cli.js +112 -6
- package/src/health.js +16 -8
- package/src/hooks.js +70 -32
- package/src/lines.js +7 -4
- package/src/message.js +81 -4
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +18 -14
- package/src/record.js +5 -5
- package/src/temp.js +156 -32
- package/src/up.js +7 -6
package/src/cli.js
CHANGED
|
@@ -17,19 +17,20 @@ import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
|
|
|
17
17
|
import { checkHealth, orcaSettingFindings } from './health.js';
|
|
18
18
|
import { initBots } from './init.js';
|
|
19
19
|
import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
|
|
20
|
-
import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
20
|
+
import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
21
21
|
import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
|
|
22
22
|
import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
|
|
23
23
|
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
|
+
import { clearSession, compactSession } from './clear.js';
|
|
26
27
|
import { retireBot, retireSession } from './retire.js';
|
|
27
28
|
import { sentWarning } from './sent.js';
|
|
28
29
|
import { readRoster } from './roster.js';
|
|
29
30
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
30
31
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
31
32
|
import { addSource, fetchSources } from './sources.js';
|
|
32
|
-
import { makeTemp, retireTemp, trustHooks } from './temp.js';
|
|
33
|
+
import { listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
|
|
33
34
|
import { readUsage } from './usage.js';
|
|
34
35
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
35
36
|
|
|
@@ -99,7 +100,7 @@ Usage:
|
|
|
99
100
|
project holds a tab your book does not name, and
|
|
100
101
|
moves the folder only once Orca no longer lists
|
|
101
102
|
the project.
|
|
102
|
-
obk temp make --bots <path> --name <session>
|
|
103
|
+
obk temp make --bots <path> --name <session> [--role <role>[:<option>]]
|
|
103
104
|
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
104
105
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
105
106
|
[--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
|
|
@@ -113,6 +114,19 @@ Usage:
|
|
|
113
114
|
work/<session>, and has the task as its start
|
|
114
115
|
prompt. The book records it as temporary, made by
|
|
115
116
|
you.
|
|
117
|
+
--role takes the harness, model, effort and context
|
|
118
|
+
of an option of a role in the bot's temp_roles, the
|
|
119
|
+
role's first option when none is named, and the
|
|
120
|
+
role's prompt file when no task is given; a flag
|
|
121
|
+
still wins, and what the option leaves out is
|
|
122
|
+
taken as above. The session is named
|
|
123
|
+
<role>-<session>. A role's cap, if it has one,
|
|
124
|
+
counts its open sessions. The answer says where
|
|
125
|
+
each setting came from.
|
|
126
|
+
obk temp roles --bots <path>
|
|
127
|
+
Run in a session's own tab: list the roles of your
|
|
128
|
+
bot's temp_roles, each option with what it is for,
|
|
129
|
+
and each role's cap and open sessions.
|
|
116
130
|
obk temp retire --bots <path> --name <session>
|
|
117
131
|
Run in the maker's own tab: retire a temporary
|
|
118
132
|
session it made, as obk retire does. It refuses a
|
|
@@ -191,6 +205,18 @@ Usage:
|
|
|
191
205
|
one command that closes a tab, it closes only the
|
|
192
206
|
tabs your book names, and it will not close one
|
|
193
207
|
whose conversation the book cannot name.
|
|
208
|
+
obk session clear --bots <path> --bot <bot> --session <name>
|
|
209
|
+
Start the session on a new conversation: /clear on
|
|
210
|
+
Claude Code, /new on Codex. It waits up to 30 s for
|
|
211
|
+
the session to be idle, types nothing into a busy
|
|
212
|
+
one or one with a question on its screen, and
|
|
213
|
+
answers once the book holds the new conversation.
|
|
214
|
+
The kit's hook gives it its start prompt again.
|
|
215
|
+
obk session compact --bots <path> --bot <bot> --session <name>
|
|
216
|
+
Compact the session's conversation with /compact,
|
|
217
|
+
as safely as session clear, and answer once the
|
|
218
|
+
harness's record shows it, or after 5 minutes that
|
|
219
|
+
it is not confirmed yet.
|
|
194
220
|
obk health --bots <path> [--bot <bot>]
|
|
195
221
|
Say what is wrong with your setup: configuration
|
|
196
222
|
that will not work, a skill that is not where its
|
|
@@ -242,6 +268,12 @@ Usage:
|
|
|
242
268
|
Claude session's native message, it warns the
|
|
243
269
|
session when the address was none of this bots
|
|
244
270
|
folder's own. It never stops a message.
|
|
271
|
+
obk session nudge --bots <path> --bot <bot>
|
|
272
|
+
For the kit's own hook, not for typing: after a
|
|
273
|
+
Codex session's shell command, it decides the mail
|
|
274
|
+
nudges a send there left because it could not
|
|
275
|
+
tell from inside Codex's sandbox, and types each
|
|
276
|
+
one or says why not.
|
|
245
277
|
obk session mailbox --bots <path> --bot <bot> --session <name>
|
|
246
278
|
For the kit's own launch line, not for typing: run
|
|
247
279
|
in the session's own tab, it gives the session its
|
|
@@ -277,13 +309,17 @@ const COMMANDS = {
|
|
|
277
309
|
'source add': ['bots', 'name', 'repo', 'ref'],
|
|
278
310
|
'session add': ['bots', 'bot', 'name'],
|
|
279
311
|
'session change': ['bots', 'bot', 'session'],
|
|
312
|
+
'session clear': ['bots', 'bot', 'session'],
|
|
313
|
+
'session compact': ['bots', 'bot', 'session'],
|
|
280
314
|
'message to': ['bots', 'to'],
|
|
281
315
|
'message send': ['bots', 'to', 'subject'],
|
|
282
316
|
'message check': ['bots'],
|
|
283
317
|
'session record': ['bots', 'bot'],
|
|
284
318
|
'session sent': ['bots', 'bot'],
|
|
319
|
+
'session nudge': ['bots', 'bot'],
|
|
285
320
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
286
321
|
'temp make': ['bots', 'name'],
|
|
322
|
+
'temp roles': ['bots'],
|
|
287
323
|
'temp retire': ['bots', 'name'],
|
|
288
324
|
'temp trust-hooks': ['bots', 'name'],
|
|
289
325
|
};
|
|
@@ -306,6 +342,7 @@ const NEEDED = {
|
|
|
306
342
|
to: '--to <bot>/<session>: which session to write to',
|
|
307
343
|
from: '--from <bot>/<session>: which session is writing',
|
|
308
344
|
subject: '--subject <text>: what the message is about',
|
|
345
|
+
role: '--role <role>[:<option>]: which role of the bot\'s temp_roles',
|
|
309
346
|
};
|
|
310
347
|
|
|
311
348
|
/** The flags that name something. A name that is empty names nothing. */
|
|
@@ -360,6 +397,7 @@ async function run(argv) {
|
|
|
360
397
|
...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
|
|
361
398
|
'extra-arg': { type: 'string', multiple: true },
|
|
362
399
|
'run-on': { type: 'string' },
|
|
400
|
+
role: { type: 'string' },
|
|
363
401
|
json: { type: 'boolean' },
|
|
364
402
|
help: { type: 'boolean', short: 'h' },
|
|
365
403
|
version: { type: 'boolean' },
|
|
@@ -410,6 +448,7 @@ async function run(argv) {
|
|
|
410
448
|
// The send hook is quiet whatever it is given, a bots folder that is not
|
|
411
449
|
// there included, so it goes before anything that can complain (ADR 0032).
|
|
412
450
|
if (command === SENT) return sent(path.resolve(values.bots));
|
|
451
|
+
if (command === NUDGE) return nudgeLeft();
|
|
413
452
|
|
|
414
453
|
// One fleet, one identity, whatever spelling of its path was given (#164).
|
|
415
454
|
const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
|
|
@@ -477,6 +516,7 @@ const ANSWER_IT = [
|
|
|
477
516
|
/** The commands a harness runs rather than a person: the kit's hooks. */
|
|
478
517
|
const RECORD = 'session record';
|
|
479
518
|
const SENT = 'session sent';
|
|
519
|
+
const NUDGE = 'session nudge';
|
|
480
520
|
|
|
481
521
|
/**
|
|
482
522
|
* What the kit's send hook says after a native message: a warning the session
|
|
@@ -495,6 +535,27 @@ function sent(bots) {
|
|
|
495
535
|
return 0;
|
|
496
536
|
}
|
|
497
537
|
|
|
538
|
+
/**
|
|
539
|
+
* What the kit's Codex hook does after a shell command: decide the nudges a
|
|
540
|
+
* send in this tab left for it, outside Codex's sandbox, and say to the session
|
|
541
|
+
* what became of each, as Codex's `additionalContext` (#350, ADR 0034). Silent
|
|
542
|
+
* when nothing was left, never a decision, and never anything but exit 0.
|
|
543
|
+
*/
|
|
544
|
+
function nudgeLeft() {
|
|
545
|
+
try {
|
|
546
|
+
const said = JSON.parse(readFileSync(0, 'utf8'));
|
|
547
|
+
if (said?.hook_event_name !== 'PostToolUse') return 0;
|
|
548
|
+
const decided = decideLeftNudges(process.env[TAB_ENV]);
|
|
549
|
+
if (decided.length > 0) {
|
|
550
|
+
const words = decided.map((one) => `Your mail "${one.subject}" to ${one.to}: ${one.nudged ? "the kit's hook nudged its tab, from outside Codex's sandbox:" : "the kit's hook looked at its tab from outside Codex's sandbox, and"} ${nudgeLine(one, one.to).trim()}`);
|
|
551
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: words.join('\n') } })}\n`);
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
// Nothing: a hook does not disturb the session it runs in.
|
|
555
|
+
}
|
|
556
|
+
return 0;
|
|
557
|
+
}
|
|
558
|
+
|
|
498
559
|
/**
|
|
499
560
|
* What the kit's hook does with what the harness told it, and what it answers.
|
|
500
561
|
*
|
|
@@ -647,21 +708,41 @@ const commands = {
|
|
|
647
708
|
const tab = callerTab(bots, true);
|
|
648
709
|
refuseWhenOrcaIsDown();
|
|
649
710
|
const given = { name: values.name };
|
|
650
|
-
for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file']]) {
|
|
711
|
+
for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file'], ['role', 'role']]) {
|
|
651
712
|
if (values[flag] !== undefined) given[key] = values[flag];
|
|
652
713
|
}
|
|
653
714
|
if (values.context !== undefined) given.context = asNumberOrText(values.context);
|
|
654
715
|
if (values['extra-arg'] !== undefined) given.extra_args = values['extra-arg'];
|
|
655
716
|
const made = await makeTemp(bots, { tab, ...given });
|
|
656
717
|
const { tabs, rules, skills, permissions, paused, projects } = made.up;
|
|
657
|
-
const answer = {
|
|
718
|
+
const answer = {
|
|
719
|
+
bots, bot: made.bot, session: made.session, maker: made.maker, ...(made.role === undefined ? {} : { role: made.role }), settings: made.settings, chosen: made.chosen,
|
|
720
|
+
created: [], completed: [], rules, skills, permissions, tabs, paused, projects,
|
|
721
|
+
};
|
|
658
722
|
const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
|
|
659
723
|
return {
|
|
660
724
|
answer,
|
|
661
|
-
lines:
|
|
725
|
+
lines: [
|
|
726
|
+
...(made.role === undefined ? [] : [`role ${made.role.name}:${made.role.option}${made.role.for === undefined ? '' : `, for ${made.role.for}`}. Its options: ${optionsLine(made.role.options)}`]),
|
|
727
|
+
...Object.entries(made.chosen).map(([setting, one]) => `${setting.padEnd(9)} ${one.value === undefined ? `the ${one.from}` : `${one.value}, from ${one.from === 'flag' ? `--${setting}` : `the ${one.from}`}`}`),
|
|
728
|
+
...tabLines(answer, `Made ${made.bot}/${made.session}, a temporary session of ${made.maker}'s, working in work/${made.session}. Retire it when its work is done: ${retire}`),
|
|
729
|
+
],
|
|
662
730
|
};
|
|
663
731
|
},
|
|
664
732
|
|
|
733
|
+
'temp roles'(bots) {
|
|
734
|
+
const tab = callerTab(bots, true);
|
|
735
|
+
const listed = listRoles(bots, { tab });
|
|
736
|
+
const lines = listed.roles.length === 0
|
|
737
|
+
? [`${listed.bot} has no roles for temporary sessions: its bot.yaml has no temp_roles.`]
|
|
738
|
+
: listed.roles.flatMap((role) => [
|
|
739
|
+
`role ${role.name}${role.cap === undefined ? '' : `, cap ${role.cap}`}, open: ${role.open.length === 0 ? 'none' : role.open.join(', ')}${role.prompt_file === undefined ? '' : `, prompt file ${role.prompt_file}`}`,
|
|
740
|
+
...role.options.map((option) => ` ${option.default ? 'default' : 'option '} ${role.name}:${option.name}${option.for === undefined ? '' : `, for ${option.for}`}: ${['harness', 'model', 'effort', 'context'].filter((key) => option[key] !== undefined).map((key) => `${key} ${option[key]}`).join(', ') || 'all as you have them'}`),
|
|
741
|
+
...(role.trouble ?? []).map((says) => ` trouble ${says}`),
|
|
742
|
+
]);
|
|
743
|
+
return { answer: { bots, ...listed }, lines };
|
|
744
|
+
},
|
|
745
|
+
|
|
665
746
|
async 'temp retire'(bots, values) {
|
|
666
747
|
const tab = callerTab(bots, true);
|
|
667
748
|
refuseWhenOrcaIsDown();
|
|
@@ -1070,6 +1151,28 @@ const commands = {
|
|
|
1070
1151
|
],
|
|
1071
1152
|
};
|
|
1072
1153
|
},
|
|
1154
|
+
|
|
1155
|
+
async 'session clear'(bots, values) {
|
|
1156
|
+
refuseWhenOrcaIsDown();
|
|
1157
|
+
const cleared = await clearSession(bots, { bot: values.bot, session: values.session });
|
|
1158
|
+
const after = cleared.was === null ? '' : `, in place of ${cleared.was}`;
|
|
1159
|
+
return {
|
|
1160
|
+
answer: { bots, cleared },
|
|
1161
|
+
lines: [`cleared ${cleared.bot} ${cleared.session}: the book holds its new conversation ${cleared.now}${after}.`],
|
|
1162
|
+
};
|
|
1163
|
+
},
|
|
1164
|
+
|
|
1165
|
+
async 'session compact'(bots, values) {
|
|
1166
|
+
refuseWhenOrcaIsDown();
|
|
1167
|
+
const compacted = await compactSession(bots, { bot: values.bot, session: values.session });
|
|
1168
|
+
const what = `${compacted.bot} ${compacted.session}`;
|
|
1169
|
+
return {
|
|
1170
|
+
answer: { bots, compacted },
|
|
1171
|
+
lines: [compacted.confirmed
|
|
1172
|
+
? `compacted ${what}: the harness's record of its conversation ${compacted.conversation} shows the compaction.`
|
|
1173
|
+
: `asked ${what} to compact: /compact went in, and after 5 minutes the harness's record shows no compaction, so it is not confirmed yet. Look at its tab.`],
|
|
1174
|
+
};
|
|
1175
|
+
},
|
|
1073
1176
|
};
|
|
1074
1177
|
|
|
1075
1178
|
/**
|
|
@@ -1107,6 +1210,9 @@ function nudgeLine(answer, where) {
|
|
|
1107
1210
|
if (answer.blocked !== undefined) {
|
|
1108
1211
|
return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
|
|
1109
1212
|
}
|
|
1213
|
+
if (answer.nudgeLeft) {
|
|
1214
|
+
return ` it is queued, and its tab could not be told to look from here: ${answer.nudgeTrouble}. So the nudge is left for this session's hook, which looks at the tab from outside Codex's sandbox when this command is done, and says what it did.`;
|
|
1215
|
+
}
|
|
1110
1216
|
if (answer.nudgeTrouble !== undefined) {
|
|
1111
1217
|
return ` it is queued, and its tab could not be told to look: ${answer.nudgeTrouble}`;
|
|
1112
1218
|
}
|
package/src/health.js
CHANGED
|
@@ -22,7 +22,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
|
-
import { botDir, botNames, botsDir, readBot, unknownKeys } from './bot.js';
|
|
25
|
+
import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
28
|
import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
@@ -190,6 +190,7 @@ function aboutBot(bots, name, setups, sessions) {
|
|
|
190
190
|
return [
|
|
191
191
|
...unknownKeys(home, name).map(said('config')),
|
|
192
192
|
...sessionSettings(home, bot).map(said('config')),
|
|
193
|
+
...rolesTrouble(home, bot).map(said('config')),
|
|
193
194
|
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
194
195
|
...hooksOf(bots, home, bot).map(said('config')),
|
|
195
196
|
...permissionsTrouble(home, bot).map(said('config')),
|
|
@@ -209,6 +210,16 @@ function sessionSettings(home, bot) {
|
|
|
209
210
|
});
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/** What is wrong with the roles the bot's temporary sessions are made in (#465). */
|
|
214
|
+
function rolesTrouble(home, bot) {
|
|
215
|
+
const where = path.join(home, 'bot.yaml');
|
|
216
|
+
try {
|
|
217
|
+
return tempRoles(home, bot).flatMap((role) => role.trouble.map((says) => ({ where, says })));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return [{ where, says: error.message }];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
212
223
|
/** The kit's own hook, in the file of every harness this bot's sessions run on. */
|
|
213
224
|
const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)))]
|
|
214
225
|
.filter((harness) => HARNESSES.includes(harness))
|
|
@@ -311,13 +322,10 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
311
322
|
|
|
312
323
|
if (rules.state === 'older') {
|
|
313
324
|
const agents = path.join(home, 'AGENTS.md');
|
|
314
|
-
// A clear
|
|
315
|
-
// (tech notes,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
|
|
319
|
-
: `A Codex session reads it when it starts: ${restart}`;
|
|
320
|
-
found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
|
|
325
|
+
// A clear reads the instructions again, as a start does, on both
|
|
326
|
+
// harnesses (tech notes, sections 2 and 3; #391).
|
|
327
|
+
const clear = `${shellWord(ownCli())} session clear --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
328
|
+
found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. A session reads it again when it is cleared: ${clear}, or when it starts: ${restart}`, bot.name));
|
|
321
329
|
}
|
|
322
330
|
}
|
|
323
331
|
return found;
|
package/src/hooks.js
CHANGED
|
@@ -54,6 +54,21 @@ const SENT_MATCHER = 'SendMessage';
|
|
|
54
54
|
export const sentCommand = (bots, bot, cli = ownCli()) =>
|
|
55
55
|
`${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Codex's second kit hook: after each command its shell tool ran, outside
|
|
59
|
+
* Codex's sandbox, it decides the mail nudges a send in that command could not
|
|
60
|
+
* (#350, ADR 0034). Codex only, since only Codex runs its commands where `ps`
|
|
61
|
+
* does not start. It types into another session's tab, which takes Orca a few
|
|
62
|
+
* seconds a nudge, so it is given longer than the others.
|
|
63
|
+
*/
|
|
64
|
+
const NUDGE_EVENT = 'PostToolUse';
|
|
65
|
+
const NUDGE_MATCHER = 'Bash';
|
|
66
|
+
const NUDGE_TIMEOUT = 30;
|
|
67
|
+
|
|
68
|
+
/** What the nudge hook runs, by the kit's own path, as `hookCommand` does. */
|
|
69
|
+
export const nudgeCommand = (bots, bot, cli = ownCli()) =>
|
|
70
|
+
`${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
73
|
* Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
|
|
59
74
|
* Returns the file it wrote, relative to the bot home, or undefined when the
|
|
@@ -76,8 +91,7 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
76
91
|
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
77
92
|
// stays where they put it.
|
|
78
93
|
const started = withKitHook(settings.hooks, file, mine);
|
|
79
|
-
const
|
|
80
|
-
const wanted = { ...settings, hooks: harness === 'claude' ? withSentHook(started, sentHook) : started };
|
|
94
|
+
const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
|
|
81
95
|
|
|
82
96
|
// Compared as documents, not as text: how the user laid their file out is
|
|
83
97
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -124,24 +138,25 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
124
138
|
}
|
|
125
139
|
const program = running ?? ownCli();
|
|
126
140
|
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
127
|
-
// The
|
|
128
|
-
// the warning after a native message
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
// The harness's second hook is judged on its own: the session hook may be
|
|
142
|
+
// right while the warning after a native message (ADR 0032), or the nudge
|
|
143
|
+
// a Codex sender left (ADR 0034), is missing.
|
|
144
|
+
if (isDeepStrictEqual(settings, wanted)) {
|
|
145
|
+
const tool = toolHookOf(harness, bots, bot);
|
|
146
|
+
const toolRunning = programIn(settings, tool.pattern);
|
|
147
|
+
// One that runs a kit no longer there is as quiet as a missing one:
|
|
148
|
+
// `|| true` hides it (#451 review).
|
|
149
|
+
if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
|
|
134
150
|
return {
|
|
135
151
|
where: file,
|
|
136
|
-
says: `${file} holds the kit's
|
|
152
|
+
says: `${file} holds the kit's ${tool.event} hook for ${tool.matcher}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
|
|
137
153
|
};
|
|
138
154
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
if (!isDeepStrictEqual(settings, withSent)) {
|
|
155
|
+
const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
|
|
156
|
+
if (!isDeepStrictEqual(settings, withTool)) {
|
|
142
157
|
return {
|
|
143
158
|
where: file,
|
|
144
|
-
says: `${file} does not hold the kit's
|
|
159
|
+
says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
|
|
145
160
|
};
|
|
146
161
|
}
|
|
147
162
|
}
|
|
@@ -216,21 +231,46 @@ function withKitHook(hooks, file, mine) {
|
|
|
216
231
|
}
|
|
217
232
|
|
|
218
233
|
/**
|
|
219
|
-
*
|
|
220
|
-
* `SendMessage
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* group of its own when there is none. A group the kit empties goes.
|
|
234
|
+
* Each harness's second kit hook, after one of its tools: Claude Code's after a
|
|
235
|
+
* native `SendMessage` (ADR 0032), Codex's after its shell tool (ADR 0034).
|
|
236
|
+
* The entry, where it goes, how the kit knows its own, and what is lost
|
|
237
|
+
* without it.
|
|
224
238
|
*/
|
|
225
|
-
function
|
|
239
|
+
function toolHookOf(harness, bots, bot, cli = ownCli()) {
|
|
240
|
+
if (harness === 'claude') {
|
|
241
|
+
return {
|
|
242
|
+
event: SENT_EVENT,
|
|
243
|
+
matcher: SENT_MATCHER,
|
|
244
|
+
pattern: KIT_SENT,
|
|
245
|
+
mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
|
|
246
|
+
missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
event: NUDGE_EVENT,
|
|
251
|
+
matcher: NUDGE_MATCHER,
|
|
252
|
+
pattern: KIT_NUDGE,
|
|
253
|
+
mine: { type: 'command', command: nudgeCommand(bots, bot, cli), timeout: NUDGE_TIMEOUT },
|
|
254
|
+
missing: "mail a session sends from inside Codex's sandbox never tells a receiver the kit could not see from there, such as a Claude session busy with a command (ADR 0034)",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The hooks with the kit's tool hook in a group of its event matched to its
|
|
260
|
+
* tool, and everything else as the user left it, as `withKitHook` keeps the
|
|
261
|
+
* session hook: one entry of the kit's, written in place where it already sits
|
|
262
|
+
* in such a group, taken out of anywhere else, and added in a group of its own
|
|
263
|
+
* when there is none. A group the kit empties goes.
|
|
264
|
+
*/
|
|
265
|
+
function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
|
|
226
266
|
let placed = false;
|
|
227
267
|
const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
228
268
|
if (!Array.isArray(groups)) return [[event, groups]];
|
|
229
269
|
const kept = groups.flatMap((group) => {
|
|
230
270
|
if (!Array.isArray(group?.hooks)) return [group];
|
|
231
271
|
const entries = group.hooks.flatMap((hook) => {
|
|
232
|
-
if (
|
|
233
|
-
if (event !==
|
|
272
|
+
if (programOf(hook?.command, pattern) === undefined) return [hook];
|
|
273
|
+
if (event !== wantedEvent || group.matcher !== matcher || placed) return [];
|
|
234
274
|
placed = true;
|
|
235
275
|
return [mine];
|
|
236
276
|
});
|
|
@@ -239,27 +279,24 @@ function withSentHook(hooks, mine) {
|
|
|
239
279
|
return kept.length === 0 ? [] : [[event, kept]];
|
|
240
280
|
}));
|
|
241
281
|
if (placed) return events;
|
|
242
|
-
return { ...events, [
|
|
282
|
+
return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
|
|
243
283
|
}
|
|
244
284
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
function sentProgramOf(command) {
|
|
250
|
-
const found = typeof command === 'string' ? KIT_SENT.exec(command) : null;
|
|
285
|
+
/** The program a command of the kit's of this pattern runs, unquoted, or undefined for any other command. */
|
|
286
|
+
function programOf(command, pattern) {
|
|
287
|
+
const found = typeof command === 'string' ? pattern.exec(command) : null;
|
|
251
288
|
if (found === null) return undefined;
|
|
252
289
|
const program = unquoted(found[1]);
|
|
253
290
|
return isKitCli(program) ? program : undefined;
|
|
254
291
|
}
|
|
255
292
|
|
|
256
|
-
/** What the kit's
|
|
257
|
-
function
|
|
293
|
+
/** What the kit's hook of this pattern in these settings runs, or undefined when there is none. */
|
|
294
|
+
function programIn(settings, pattern) {
|
|
258
295
|
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
259
296
|
if (!Array.isArray(groups)) continue;
|
|
260
297
|
for (const group of groups) {
|
|
261
298
|
for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
|
|
262
|
-
const program =
|
|
299
|
+
const program = programOf(hook?.command, pattern);
|
|
263
300
|
if (program !== undefined) return program;
|
|
264
301
|
}
|
|
265
302
|
}
|
|
@@ -282,6 +319,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
|
282
319
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
283
320
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
284
321
|
const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
322
|
+
const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
285
323
|
|
|
286
324
|
/** The program a bot made before the kit named itself by path runs. */
|
|
287
325
|
const BARE = 'obk';
|
package/src/lines.js
CHANGED
|
@@ -25,18 +25,21 @@ const LONGEST = 64 * 1024 * 1024;
|
|
|
25
25
|
/**
|
|
26
26
|
* Each line of `file`, in order, to `onLine`: its text, or `undefined` for a
|
|
27
27
|
* line too long to hold. A line `onLine` answers `true` to is the last one
|
|
28
|
-
* read.
|
|
29
|
-
*
|
|
28
|
+
* read. With `from`, reading starts at that byte, for a caller that wants only
|
|
29
|
+
* what was written after it (#391). Throws what the file system throws when
|
|
30
|
+
* the file cannot be opened or read.
|
|
30
31
|
*/
|
|
31
|
-
export function eachLine(file, onLine) {
|
|
32
|
+
export function eachLine(file, onLine, { from = 0 } = {}) {
|
|
32
33
|
const fd = openSync(file, 'r');
|
|
33
34
|
try {
|
|
34
35
|
const decoder = new StringDecoder('utf8');
|
|
35
36
|
const buffer = Buffer.allocUnsafe(CHUNK);
|
|
36
37
|
let pending = '';
|
|
37
38
|
let tooLong = false;
|
|
39
|
+
let position = from;
|
|
38
40
|
for (;;) {
|
|
39
|
-
const read = readSync(fd, buffer, 0, CHUNK,
|
|
41
|
+
const read = readSync(fd, buffer, 0, CHUNK, position);
|
|
42
|
+
position += read;
|
|
40
43
|
const text = read === 0 ? decoder.end() : decoder.write(buffer.subarray(0, read));
|
|
41
44
|
let start = 0;
|
|
42
45
|
for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', start)) {
|
package/src/message.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
// one line into the receiver's tab. Both harnesses queue a typed line while
|
|
16
16
|
// they are busy, which is what PRD 6.9 means by queued and not interrupting.
|
|
17
17
|
|
|
18
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
19
20
|
import path from 'node:path';
|
|
20
21
|
|
|
21
22
|
import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
|
|
@@ -172,7 +173,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
|
|
|
172
173
|
id: message.id,
|
|
173
174
|
thread: message.thread_id ?? thread,
|
|
174
175
|
file: written.file,
|
|
175
|
-
...nudge(to, from, subject),
|
|
176
|
+
...nudge(to, from, subject, tab),
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
|
|
@@ -452,14 +453,14 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
|
|
|
452
453
|
* it is nobody sending to a session before its first screens are answered,
|
|
453
454
|
* which is the caller's work either way.
|
|
454
455
|
*/
|
|
455
|
-
function nudge(to, from, subject) {
|
|
456
|
+
function nudge(to, from, subject, tab) {
|
|
456
457
|
if (to.tab === undefined) return { nudged: false };
|
|
457
458
|
|
|
458
459
|
try {
|
|
459
460
|
const found = lookAt(to);
|
|
460
461
|
if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
|
|
461
462
|
// A line that lands in a shell is run there, with the sender's subject in it.
|
|
462
|
-
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
|
|
463
|
+
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure, ...(found.psUnread ? leftForHook(to, from, subject, tab) : {}) };
|
|
463
464
|
if (found.handle === undefined) return { nudged: false };
|
|
464
465
|
|
|
465
466
|
const sent = typeIntoTab(
|
|
@@ -485,6 +486,82 @@ function nudge(to, from, subject) {
|
|
|
485
486
|
}
|
|
486
487
|
}
|
|
487
488
|
|
|
489
|
+
/**
|
|
490
|
+
* The nudge a Codex sender could not decide, left for its own hook (#350, ADR
|
|
491
|
+
* 0034): `{ nudgeLeft: true }`, or nothing when it is not left.
|
|
492
|
+
*
|
|
493
|
+
* Inside Codex's sandbox `ps` does not start, and for a Claude receiver busy
|
|
494
|
+
* with a command Orca's runtime cannot see past it either. Codex runs its
|
|
495
|
+
* hooks outside the sandbox, and the kit's `PostToolUse` hook runs right after
|
|
496
|
+
* the command that made this send, in the same tab, where `ps` runs. So the
|
|
497
|
+
* send leaves the nudge where that hook looks, the system temp folder under the
|
|
498
|
+
* tab's id, which the sandbox lets it write and the hook reads. Only for a
|
|
499
|
+
* Codex session sending from its own tab, and only where `ps` could not read
|
|
500
|
+
* the receiver's tab: anywhere else no hook would see it, or would see no more
|
|
501
|
+
* than the send did.
|
|
502
|
+
*/
|
|
503
|
+
function leftForHook(to, from, subject, tab) {
|
|
504
|
+
if (tab === undefined || from.tab !== tab || from.harness !== 'codex') return {};
|
|
505
|
+
try {
|
|
506
|
+
const dir = leftDir(tab);
|
|
507
|
+
mkdirSync(dir, { recursive: true });
|
|
508
|
+
const left = { bots: to.bots, to: `${to.bot}/${to.session}`, from: `${from.bot}/${from.session}`, subject };
|
|
509
|
+
// Written under a name the hook does not take, then put in place whole: a
|
|
510
|
+
// hook running beside this send never takes a file still being written.
|
|
511
|
+
const name = path.join(dir, `${stamp()}.${process.pid}`);
|
|
512
|
+
writeFileSync(`${name}.writing`, `${JSON.stringify(left)}\n`);
|
|
513
|
+
renameSync(`${name}.writing`, `${name}.json`);
|
|
514
|
+
return { nudgeLeft: true };
|
|
515
|
+
} catch {
|
|
516
|
+
return {};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Where the nudges left from one tab wait for its hook. */
|
|
521
|
+
const leftDir = (tab) => path.join(os.tmpdir(), 'obk-nudges', encodeURIComponent(tab));
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* What the kit's Codex hook does after each shell command (#350): decide every
|
|
525
|
+
* nudge a send in this tab left, oldest first, with the gate the send uses, and
|
|
526
|
+
* type it or not. Each is taken out before it is looked at, so it is decided
|
|
527
|
+
* once, by one hook. Returns `{ to, subject, ...what the nudge came to }` for
|
|
528
|
+
* each, as the send's own answer says it.
|
|
529
|
+
*/
|
|
530
|
+
export function decideLeftNudges(tab) {
|
|
531
|
+
if (tab === undefined) return [];
|
|
532
|
+
const dir = leftDir(tab);
|
|
533
|
+
let names;
|
|
534
|
+
try {
|
|
535
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
return names.flatMap((name) => {
|
|
540
|
+
const taking = path.join(dir, `${name}.taking`);
|
|
541
|
+
// Another hook took it first: it is that hook's to decide, and to remove.
|
|
542
|
+
try {
|
|
543
|
+
renameSync(path.join(dir, name), taking);
|
|
544
|
+
} catch {
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
let left;
|
|
548
|
+
try {
|
|
549
|
+
left = JSON.parse(readFileSync(taking, 'utf8'));
|
|
550
|
+
} catch {
|
|
551
|
+
return [];
|
|
552
|
+
} finally {
|
|
553
|
+
rmSync(taking, { force: true });
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const to = findSession(left.bots, left.to);
|
|
557
|
+
const from = findSession(left.bots, left.from);
|
|
558
|
+
return [{ to: left.to, subject: left.subject, ...nudge(to, from, left.subject) }];
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return [{ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message }];
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
488
565
|
/** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
|
|
489
566
|
function unseen(sent) {
|
|
490
567
|
const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
-- Run by the kit as `osascript orca-reload.applescript <Orca.app path>`, after
|
|
2
|
-
-- a project was removed (#343, ADR
|
|
2
|
+
-- a project was removed (#343, ADR 0034).
|
|
3
3
|
--
|
|
4
4
|
-- Orca's window keeps a removed project in its sidebar until the window is
|
|
5
5
|
-- rebuilt (stablyai/orca#23224; once a release fixes it, this can go behind a
|
package/src/orca-runtime.cjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-runtime.cjs <client> <method> <params> <wait>`.
|
|
3
3
|
//
|
|
4
4
|
// It makes one call to Orca's runtime, through Orca's own client, for a method
|
|
5
|
-
// Orca's CLI does not offer (ADR
|
|
5
|
+
// Orca's CLI does not offer (ADR 0034): `project.update`, which makes Orca's
|
|
6
6
|
// window read its projects again (#224), and `terminal.inspectProcess`, which
|
|
7
7
|
// says who is in front of a tab where `ps` cannot (#298). `params` is JSON. It
|
|
8
8
|
// prints the runtime's answer as JSON and exits 0 only when the runtime took
|