@assuredloop/orca-bot-kit 0.13.0 → 0.15.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 +1 -1
- package/SETUP.md +1 -1
- package/package.json +1 -1
- package/rules/temporary.md +21 -0
- package/skills/obk-bot-building/SKILL.md +6 -4
- package/skills/obk-debugging/SKILL.md +10 -6
- package/skills/obk-fleet-review/SKILL.md +12 -0
- package/skills/obk-grilling/NOTICE.md +1 -1
- package/skills/obk-grooming/SKILL.md +19 -0
- package/src/book.js +3 -0
- package/src/bot.js +21 -1
- package/src/cli.js +91 -9
- package/src/health.js +5 -1
- package/src/init.js +3 -2
- package/src/launch.js +2 -2
- package/src/message.js +43 -7
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +77 -19
- package/src/pause.js +2 -2
- package/src/restart.js +41 -4
- package/src/retire.js +34 -13
- package/src/temp.js +173 -0
- package/src/up.js +2 -2
package/README.md
CHANGED
|
@@ -508,7 +508,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
|
|
|
508
508
|
## Sessions and bots that talk
|
|
509
509
|
|
|
510
510
|
Ask the kit how to reach a session, and it answers with the road and the
|
|
511
|
-
address ([ADR
|
|
511
|
+
address ([ADR 0030](docs/adr/0030-messaging-transport.md)):
|
|
512
512
|
|
|
513
513
|
```sh
|
|
514
514
|
obk message to --bots /path/to/my-bots --to api-bot/daily
|
package/SETUP.md
CHANGED
|
@@ -199,7 +199,7 @@ waiting for:
|
|
|
199
199
|
| Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.157.1 |
|
|
200
200
|
| Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; 0.157.1 has had no newer release to offer |
|
|
201
201
|
| `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
|
|
202
|
-
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which is **
|
|
202
|
+
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it. On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live on 2.1.283 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
|
|
203
203
|
|
|
204
204
|
Each key counts on where the selection starts, as it did on the version named.
|
|
205
205
|
Read the screen in front of you before you send: if the selection is somewhere
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: temporary
|
|
3
|
+
title: Temporary sessions of your own
|
|
4
|
+
applies: all
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
For a piece of work that wants a helper, a long-lived session may make a
|
|
8
|
+
temporary session of its own bot, without asking Bot Father:
|
|
9
|
+
`"${OBK_CLI:-obk}" temp make --bots <bots> --name <name> --prompt <the task>`,
|
|
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.
|
|
12
|
+
|
|
13
|
+
A new tab can stop on a first-run screen. Answer it yourself, from the table
|
|
14
|
+
the command's output points to.
|
|
15
|
+
|
|
16
|
+
Retire it when its work is done:
|
|
17
|
+
`"${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.
|
|
@@ -289,7 +289,7 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
|
|
|
289
289
|
| Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.157.1 |
|
|
290
290
|
| Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
|
|
291
291
|
| `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
|
|
292
|
-
| Claude Code's `Teach auto mode…` | `\x1b` | Esc
|
|
292
|
+
| Claude Code's `Teach auto mode…` | `\x1b` | Esc cancels; no return | Not yet proven, below |
|
|
293
293
|
|
|
294
294
|
Each key counts on where the selection starts on the version named. Read the
|
|
295
295
|
screen before you send: if the selection is elsewhere or the options have
|
|
@@ -298,9 +298,11 @@ return: on Codex's update offer that took the highlighted **Update now**,
|
|
|
298
298
|
whatever the digit was. Codex's trust said `1. Yes, continue` on older
|
|
299
299
|
versions. Its hooks review sometimes starts on `2` already: then return alone.
|
|
300
300
|
0.157.1 has had no newer release to offer, so its update offer was proven on
|
|
301
|
-
0.156.1. Claude Code's `Teach auto mode`
|
|
302
|
-
|
|
303
|
-
|
|
301
|
+
0.156.1. Claude Code's `Teach auto mode` on 2.1.283 is a form, not a list,
|
|
302
|
+
ending `Enter to continue · Esc to cancel`: never send it a return, since
|
|
303
|
+
Enter is Continue, which scans the project, recent sessions and the machine's
|
|
304
|
+
shell history. Esc cancels in its code; that is not yet proven live. On 2.1.278
|
|
305
|
+
it was a numbered list, answered with `2. Not now`. Codex's hooks
|
|
304
306
|
question matters most. The kit's hook is how the book learns which
|
|
305
307
|
conversation the session is running, and until it is answered the
|
|
306
308
|
conversation has not started. Codex's trust applies to the repository root,
|
|
@@ -379,12 +379,16 @@ Write the regression test before the fix, at a seam where it exercises the bug
|
|
|
379
379
|
the way it actually happened; if there is no correct seam, that is itself the
|
|
380
380
|
finding.
|
|
381
381
|
|
|
382
|
-
The test is the separate author's to write, as it is for any other behaviour
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
382
|
+
The test is the separate author's to write, as it is for any other behaviour:
|
|
383
|
+
someone with a context of their own, such as a fresh subagent, another session
|
|
384
|
+
or a person, never a fork of this conversation. Give them the requirement and
|
|
385
|
+
what the correct result is, the public interface, and your reproduction as the
|
|
386
|
+
worked example; not your fix, your diff or your plan. They return the tests, the
|
|
387
|
+
command, and the failing output, failing because the bug is there rather than
|
|
388
|
+
because of a typo. `obk-tdd` has more on the brief, if you can load it. Two are
|
|
389
|
+
often better than one: one at the interface saying what a caller should have
|
|
390
|
+
got, and the smallest one that shows the fault where it lives. A test that now
|
|
391
|
+
looks wrong goes back to its author; it is not edited to fit the fix.
|
|
388
392
|
|
|
389
393
|
Watch it fail. Fix. Watch it pass. Then run the original, unminimised scenario
|
|
390
394
|
again, on the same surface the bug appeared on. A different surface, or an
|
|
@@ -227,6 +227,18 @@ ask for and you do it.
|
|
|
227
227
|
A grooming report is input to this review, not its end: check what it flags
|
|
228
228
|
against the fleet, and recommend from there.
|
|
229
229
|
|
|
230
|
+
When a grooming report names temporary sessions a bot left behind, offer the
|
|
231
|
+
user one rule for that bot's charter, with the words ready to accept, such as:
|
|
232
|
+
"Retire each temporary session you make as soon as its work is done, and
|
|
233
|
+
before you end a piece of work, check that none of yours is left." On a yes,
|
|
234
|
+
read the bot's charter, add the line, and set the whole with `obk bot change
|
|
235
|
+
--bots <folder> --bot <bot> --charter <text>`, which replaces the charter, so
|
|
236
|
+
what you pass is all of it. On a no, nothing changes, and it is not offered
|
|
237
|
+
again for the same sessions: write the declined offer in grooming's open
|
|
238
|
+
findings, next to those sessions, so it outlasts this conversation. Retire
|
|
239
|
+
none of them yourself, and send nothing to their makers: the hint is for the
|
|
240
|
+
user, and only a maker knows when its work is done.
|
|
241
|
+
|
|
230
242
|
Sort the problem before you write it up. Something the kit does wrong, or
|
|
231
243
|
cannot do, is filed where the kit's problems are filed, as one issue, in one
|
|
232
244
|
step: no draft to approve first. Something a bot is doing wrong goes back to
|
|
@@ -73,7 +73,7 @@ words where they are quoted here:
|
|
|
73
73
|
against the session log (19:12:40, 19:15:38), which is the fix the skill
|
|
74
74
|
gives.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
Five deliberate departures from `grilling` and `domain-modeling`, so they are
|
|
77
77
|
not mistaken for consolidation:
|
|
78
78
|
|
|
79
79
|
- **A round is three questions, not the whole frontier.** `grilling` says to
|
|
@@ -185,6 +185,25 @@ Never edit a memory yourself, not even to cut it. The memory is the bot's, the
|
|
|
185
185
|
harness keeps it, and a pass that rewrites what a bot remembers is a pass that
|
|
186
186
|
decides what the bot knows.
|
|
187
187
|
|
|
188
|
+
## Temporary sessions left behind
|
|
189
|
+
|
|
190
|
+
A temporary session is one a bot's own session made for a piece of work with
|
|
191
|
+
`obk temp make`, and its maker retires it when the work is done. Each bot's
|
|
192
|
+
book, `sessions.yaml` in its folder, marks one on its entry as `temporary`,
|
|
193
|
+
with its `maker` and when it was `made`. Read the books every run, a quiet one
|
|
194
|
+
included: a session left behind does no work, so the usage index never shows
|
|
195
|
+
it.
|
|
196
|
+
|
|
197
|
+
Count one as left behind when its maker is no longer one of the bot's
|
|
198
|
+
sessions, or when it was made more than a day ago. The report names each: the
|
|
199
|
+
bot and session, its maker, saying so where the maker is gone, and how old it
|
|
200
|
+
is. Put them in the open findings, so the next run says whether each is still
|
|
201
|
+
there rather than reporting it as new.
|
|
202
|
+
|
|
203
|
+
Retire none of them. Only the maker knows when the work is done: a review
|
|
204
|
+
session can be waiting on an open pull request for a re-review. What to do
|
|
205
|
+
about a bot that leaves them behind is the management session's to offer.
|
|
206
|
+
|
|
188
207
|
## Say whether this run was worth making
|
|
189
208
|
|
|
190
209
|
Every run, record which of three things happened: it found something that
|
package/src/book.js
CHANGED
|
@@ -30,6 +30,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
|
|
|
30
30
|
#
|
|
31
31
|
# \`retired\` holds what the book knew about each session \`obk retire\` took off
|
|
32
32
|
# the bot, with when, so the conversations it had are still accounted for.
|
|
33
|
+
#
|
|
34
|
+
# \`temporary\` marks a session another session of this bot made with
|
|
35
|
+
# \`obk temp make\`: which session made it, and when. Its maker retires it.
|
|
33
36
|
`;
|
|
34
37
|
|
|
35
38
|
export const bookFile = (home) => path.join(home, 'sessions.yaml');
|
package/src/bot.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// writes nothing, rather than leave a bot half made or a bot.yaml half edited.
|
|
9
9
|
|
|
10
10
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
import { isDeepStrictEqual } from 'node:util';
|
|
13
14
|
import { parse, parseDocument, stringify } from 'yaml';
|
|
@@ -21,7 +22,7 @@ export const botsDir = (bots) => path.join(bots, 'bots');
|
|
|
21
22
|
export const botDir = (bots, name) => path.join(botsDir(bots), name);
|
|
22
23
|
|
|
23
24
|
/** A bot's name: the folder it lives in, and safe to be one. */
|
|
24
|
-
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
|
+
export const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
25
26
|
|
|
26
27
|
/** The file that says what a bot is. A folder without one is not a bot. */
|
|
27
28
|
const BOT_YAML = 'bot.yaml';
|
|
@@ -114,6 +115,25 @@ function whereItLeads(target) {
|
|
|
114
115
|
export const displayName = (name) =>
|
|
115
116
|
name.split('-').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* What Orca is asked to call a bot's project. A fleet whose bots folder is under
|
|
120
|
+
* the system's temp folder, as a system test's or a live check's is, gets a mark
|
|
121
|
+
* and the name of the first folder under the temp folder, so its Bot Father is
|
|
122
|
+
* never taken for the owner's in Orca's sidebar (#401). Every other fleet's
|
|
123
|
+
* project is called what it always was. Tab titles keep `displayName` either way.
|
|
124
|
+
*/
|
|
125
|
+
export function projectName(bots, name) {
|
|
126
|
+
let under;
|
|
127
|
+
try {
|
|
128
|
+
under = path.relative(realpathSync(os.tmpdir()), realpathSync(bots));
|
|
129
|
+
} catch {
|
|
130
|
+
return displayName(name);
|
|
131
|
+
}
|
|
132
|
+
// The temp folder itself, or anywhere outside it, is not a throwaway fleet's.
|
|
133
|
+
if (under === '' || under === '..' || under.startsWith(`..${path.sep}`) || path.isAbsolute(under)) return displayName(name);
|
|
134
|
+
return `${displayName(name)} · temp fleet ${under.split(path.sep)[0]}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
/**
|
|
118
138
|
* A bot's own file: which harness its sessions run on unless they say
|
|
119
139
|
* otherwise, and the sessions it keeps.
|
package/src/cli.js
CHANGED
|
@@ -17,8 +17,8 @@ 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 } from './message.js';
|
|
21
|
-
import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
|
|
20
|
+
import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
|
|
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';
|
|
@@ -28,6 +28,7 @@ import { readRoster } from './roster.js';
|
|
|
28
28
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
29
29
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
30
30
|
import { addSource, fetchSources } from './sources.js';
|
|
31
|
+
import { makeTemp, retireTemp } from './temp.js';
|
|
31
32
|
import { readUsage } from './usage.js';
|
|
32
33
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
33
34
|
|
|
@@ -95,6 +96,21 @@ Usage:
|
|
|
95
96
|
bot whose Orca project holds a tab your book does
|
|
96
97
|
not name, and moves the folder only once Orca no
|
|
97
98
|
longer lists the project.
|
|
99
|
+
obk temp make --bots <path> --name <session>
|
|
100
|
+
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
101
|
+
[--model <m>] [--effort <e>] [--context <c>]
|
|
102
|
+
[--approval ${APPROVALS.join('|')}]
|
|
103
|
+
Run in a long-lived session's own tab: make a
|
|
104
|
+
temporary session of that session's bot for a piece
|
|
105
|
+
of work, and bring it up. It takes your harness,
|
|
106
|
+
model, effort, context and approval unless you say
|
|
107
|
+
otherwise, works in work/<session>, and has the task
|
|
108
|
+
as its start prompt. The book records it as
|
|
109
|
+
temporary, made by you.
|
|
110
|
+
obk temp retire --bots <path> --name <session>
|
|
111
|
+
Run in the maker's own tab: retire a temporary
|
|
112
|
+
session it made, as obk retire does. It refuses a
|
|
113
|
+
long-lived session, or one another session made.
|
|
98
114
|
obk rules build --bots <path> [--bot <bot>]
|
|
99
115
|
Build every bot's AGENTS.md from its charter and
|
|
100
116
|
the rule units it carries, or just the one you
|
|
@@ -242,6 +258,8 @@ const COMMANDS = {
|
|
|
242
258
|
'message check': ['bots'],
|
|
243
259
|
'session record': ['bots', 'bot'],
|
|
244
260
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
261
|
+
'temp make': ['bots', 'name'],
|
|
262
|
+
'temp retire': ['bots', 'name'],
|
|
245
263
|
};
|
|
246
264
|
|
|
247
265
|
/** What each flag is for, in the sentence a caller reads when it is missing. */
|
|
@@ -335,9 +353,9 @@ async function run(argv) {
|
|
|
335
353
|
return 1;
|
|
336
354
|
}
|
|
337
355
|
|
|
338
|
-
// `bot`, `rules`, `skills`, `session`, `source` and `
|
|
339
|
-
// two words; the rest are one.
|
|
340
|
-
const words = ['bot', 'rules', 'skills', 'session', 'source', 'message'].includes(positionals[0]) ? 2 : 1;
|
|
356
|
+
// `bot`, `rules`, `skills`, `session`, `source`, `message` and `temp` are
|
|
357
|
+
// commands of two words; the rest are one.
|
|
358
|
+
const words = ['bot', 'rules', 'skills', 'session', 'source', 'message', 'temp'].includes(positionals[0]) ? 2 : 1;
|
|
341
359
|
const command = positionals.slice(0, words).join(' ');
|
|
342
360
|
const extra = positionals.slice(words);
|
|
343
361
|
|
|
@@ -534,8 +552,6 @@ const commands = {
|
|
|
534
552
|
|
|
535
553
|
async retire(bots, values) {
|
|
536
554
|
refuseWhenOrcaIsDown();
|
|
537
|
-
const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
538
|
-
|
|
539
555
|
if (values.session !== undefined) {
|
|
540
556
|
const retired = await retireSession(bots, { bot: values.bot, session: values.session });
|
|
541
557
|
return {
|
|
@@ -543,6 +559,7 @@ const commands = {
|
|
|
543
559
|
lines: [
|
|
544
560
|
...closedLines(retired.closed),
|
|
545
561
|
`retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
|
|
562
|
+
...leftLines(retired.promptsLeft),
|
|
546
563
|
],
|
|
547
564
|
};
|
|
548
565
|
}
|
|
@@ -571,6 +588,39 @@ const commands = {
|
|
|
571
588
|
retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
|
|
572
589
|
]),
|
|
573
590
|
`retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
|
|
591
|
+
...leftLines(retired.promptsLeft),
|
|
592
|
+
],
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
async 'temp make'(bots, values) {
|
|
597
|
+
const tab = callerTab(bots, true);
|
|
598
|
+
refuseWhenOrcaIsDown();
|
|
599
|
+
const given = { name: values.name };
|
|
600
|
+
for (const [flag, key] of [['harness', 'harness'], ['model', 'model'], ['effort', 'effort'], ['approval', 'approval'], ['prompt', 'prompt'], ['prompt-file', 'prompt_file']]) {
|
|
601
|
+
if (values[flag] !== undefined) given[key] = values[flag];
|
|
602
|
+
}
|
|
603
|
+
if (values.context !== undefined) given.context = asNumberOrText(values.context);
|
|
604
|
+
const made = await makeTemp(bots, { tab, ...given });
|
|
605
|
+
const { tabs, rules, skills, permissions, paused, projects } = made.up;
|
|
606
|
+
const answer = { bots, bot: made.bot, session: made.session, maker: made.maker, settings: made.settings, created: [], completed: [], rules, skills, permissions, tabs, paused, projects };
|
|
607
|
+
const retire = `${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${made.session}`;
|
|
608
|
+
return {
|
|
609
|
+
answer,
|
|
610
|
+
lines: 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}`),
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
async 'temp retire'(bots, values) {
|
|
615
|
+
const tab = callerTab(bots, true);
|
|
616
|
+
refuseWhenOrcaIsDown();
|
|
617
|
+
const retired = await retireTemp(bots, { tab, name: values.name });
|
|
618
|
+
return {
|
|
619
|
+
answer: { bots, ...retired },
|
|
620
|
+
lines: [
|
|
621
|
+
...closedLines(retired.closed),
|
|
622
|
+
`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`,
|
|
623
|
+
...leftLines(retired.promptsLeft),
|
|
574
624
|
],
|
|
575
625
|
};
|
|
576
626
|
},
|
|
@@ -832,11 +882,12 @@ const commands = {
|
|
|
832
882
|
},
|
|
833
883
|
|
|
834
884
|
'message send'(bots, values) {
|
|
885
|
+
const tab = callerTab(bots, values.from === undefined);
|
|
835
886
|
refuseWhenOrcaIsDown();
|
|
836
887
|
const answer = sendMessage(bots, {
|
|
837
888
|
to: values.to,
|
|
838
889
|
from: values.from,
|
|
839
|
-
tab
|
|
890
|
+
tab,
|
|
840
891
|
subject: values.subject,
|
|
841
892
|
text: values.text,
|
|
842
893
|
textFile: values['text-file'],
|
|
@@ -865,11 +916,13 @@ const commands = {
|
|
|
865
916
|
},
|
|
866
917
|
|
|
867
918
|
'message check'(bots, values) {
|
|
919
|
+
// Named or not, the mail is read as the caller's own terminal when it has one.
|
|
920
|
+
const tab = callerTab(bots, values.bot === undefined || process.env[TERMINAL_ENV] !== undefined);
|
|
868
921
|
refuseWhenOrcaIsDown();
|
|
869
922
|
const answer = checkMail(bots, {
|
|
870
923
|
bot: values.bot,
|
|
871
924
|
session: values.session,
|
|
872
|
-
tab
|
|
925
|
+
tab,
|
|
873
926
|
peek: values.peek === true,
|
|
874
927
|
});
|
|
875
928
|
const where = `${answer.bot}/${answer.session}`;
|
|
@@ -973,6 +1026,12 @@ function toLines(answer, bots, where) {
|
|
|
973
1026
|
* about the message.
|
|
974
1027
|
*/
|
|
975
1028
|
function nudgeLine(answer, where) {
|
|
1029
|
+
if (answer.nudged && answer.nudgeWatched === false) {
|
|
1030
|
+
return ` its tab was typed into, and Orca could not say whether it was taken: ${answer.nudgeUnseen} The message waits in its mailbox for its next check either way.`;
|
|
1031
|
+
}
|
|
1032
|
+
if (answer.nudged && answer.nudgeUnseen !== undefined) {
|
|
1033
|
+
return ' its tab was typed into, but Orca did not see the line start a turn: it may be queued behind the work in hand, or lost. The message waits in its mailbox for its next check either way.';
|
|
1034
|
+
}
|
|
976
1035
|
if (answer.nudged) return ' its tab was told to look; it will read it when it is done with what it is doing.';
|
|
977
1036
|
if (answer.blocked !== undefined) {
|
|
978
1037
|
return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
|
|
@@ -1010,11 +1069,33 @@ function settingsOf(values) {
|
|
|
1010
1069
|
return settings;
|
|
1011
1070
|
}
|
|
1012
1071
|
|
|
1072
|
+
/** The tabs a command closed, one line each. */
|
|
1073
|
+
const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
|
|
1074
|
+
|
|
1075
|
+
/** What a retire could not remove, and how to: nothing reads these files now (#393). */
|
|
1076
|
+
const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
|
|
1077
|
+
`left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
|
|
1078
|
+
` Remove it with: rm ${shellWord(file)}`,
|
|
1079
|
+
]);
|
|
1080
|
+
|
|
1013
1081
|
/** A context window written as a plain number stays one in the file. */
|
|
1014
1082
|
const asNumberOrText = (value) => (/^\d+$/.test(value) ? Number(value) : value);
|
|
1015
1083
|
|
|
1016
1084
|
const oneLine = (value) => (Array.isArray(value) ? value.join(' ') : String(value)).replace(/\s+/g, ' ').trim();
|
|
1017
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* The Orca tab this command runs in, with the caller taken to be the session
|
|
1088
|
+
* there only once sessionInTab has shown it is, when the command takes its
|
|
1089
|
+
* caller from the tab (#408). Asked before Orca is: a Codex that Orca brought
|
|
1090
|
+
* back by itself may run in a sandbox that cannot reach Orca at all, and
|
|
1091
|
+
* "Orca is not ready" would send its user the wrong way.
|
|
1092
|
+
*/
|
|
1093
|
+
function callerTab(bots, takesCaller) {
|
|
1094
|
+
const tab = process.env[TAB_ENV];
|
|
1095
|
+
if (takesCaller && tab !== undefined) sessionInTab(bots, tab);
|
|
1096
|
+
return tab;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1018
1099
|
function refuseWhenOrcaIsDown() {
|
|
1019
1100
|
const trouble = orcaTrouble();
|
|
1020
1101
|
if (trouble !== undefined) throw new Error(trouble);
|
|
@@ -1264,6 +1345,7 @@ function sessionLines(session) {
|
|
|
1264
1345
|
...(book.tab === undefined
|
|
1265
1346
|
? []
|
|
1266
1347
|
: [` tab ${book.tab}${book.launched === undefined ? '' : ` launched ${book.launched}`}`]),
|
|
1348
|
+
...(book.temporary === undefined ? [] : [` temporary, made by ${book.temporary.maker}${book.temporary.made === undefined ? '' : ` at ${book.temporary.made}`}`]),
|
|
1267
1349
|
...(book.session === undefined ? [] : [` conversation ${book.session}`]),
|
|
1268
1350
|
...(book.history ?? [])
|
|
1269
1351
|
.filter((was) => was?.session !== undefined)
|
package/src/health.js
CHANGED
|
@@ -281,7 +281,11 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
281
281
|
// see may still match, by the harness's own defaults; the rest of what
|
|
282
282
|
// bot.yaml asks for is not on it either way.
|
|
283
283
|
const drift = off.length === 0 ? '' : ` ${parts}, as the harness's own record of its conversation ${conversation} says.`;
|
|
284
|
-
|
|
284
|
+
// A Codex brought back this way also runs its commands in Codex's shared
|
|
285
|
+
// background server, under whichever tab started it (#408).
|
|
286
|
+
const daemon = harness !== 'codex' ? '' : ` Without the kit's line it also runs without --no-daemon, so its commands run in Codex's shared background server, under the tab of whichever session started that server: another tab's identity, not its own. So the kit refuses its commands that take the caller from its tab, such as reading its mail.`;
|
|
287
|
+
const held = harness !== 'codex' ? '' : ` If Codex then shows "This conversation is open in another app", Codex's shared background server still holds the conversation, and it can outlive the session that started it: once every such session is back on the kit's line, stop the server with Codex's own \`codex app-server daemon stop\`, then restart this session again. That stops the server for everything using it, Codex sessions outside the kit included, so first check that nothing else needs it.`;
|
|
288
|
+
found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} was not started by the kit: the harness in its tab ${entry.tab} carries nothing of the kit's launch line, as when Orca brings its tabs back by itself after a restart or an update. So it runs on the harness's own defaults and whatever Orca added, not on what ${path.join(home, 'bot.yaml')} asks for.${drift}${daemon} ${restart} starts it on bot.yaml.${held}`, bot.name));
|
|
285
289
|
} else if (off.length > 0) {
|
|
286
290
|
found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
|
|
287
291
|
}
|
package/src/init.js
CHANGED
|
@@ -86,8 +86,9 @@ charter: |
|
|
|
86
86
|
Good looks like: every bot has a charter its owner recognises, the skills it
|
|
87
87
|
needs and no more, and sessions that come back after a restart.
|
|
88
88
|
|
|
89
|
-
Ask first before: retiring a bot or a session, restarting
|
|
90
|
-
changing a bot's rules in a way its owner did not ask for.
|
|
89
|
+
Ask first before: retiring a bot or a long-lived session, restarting
|
|
90
|
+
anything, or changing a bot's rules in a way its owner did not ask for. A
|
|
91
|
+
temporary session is its maker's to retire, without asking.
|
|
91
92
|
|
|
92
93
|
When the kit itself goes wrong (an obk command fails in a way its own message
|
|
93
94
|
does not explain, or does something other than what it says), tell your owner
|
package/src/launch.js
CHANGED
|
@@ -51,7 +51,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* A session's name on Claude Code, which is also the address another Claude
|
|
54
|
-
* session writes to (ADR
|
|
54
|
+
* session writes to (ADR 0030). It goes on the launch line as `-n`, and it is
|
|
55
55
|
* re-applied every time the session is started: a resume keeps the name by
|
|
56
56
|
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
57
57
|
*
|
|
@@ -224,7 +224,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
224
224
|
|
|
225
225
|
if (harness === 'claude') {
|
|
226
226
|
// The name is the address other Claude sessions write to, so it goes on
|
|
227
|
-
// every launch line, a resume included (ADR
|
|
227
|
+
// every launch line, a resume included (ADR 0030).
|
|
228
228
|
if (address !== undefined) words.push('-n', address);
|
|
229
229
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
230
230
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
package/src/message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `obk message`: one session writing to another (PRD 6.9, ADR
|
|
1
|
+
// `obk message`: one session writing to another (PRD 6.9, ADR 0030).
|
|
2
2
|
//
|
|
3
3
|
// Two roads, and the bot never picks. Claude to Claude in the same approval
|
|
4
4
|
// class is the harness's own messaging, which no command line can send for it —
|
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
|
|
21
21
|
import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
|
|
22
22
|
import { botDir, botNames, readBot } from './bot.js';
|
|
23
|
-
import { harnessOf, isAddressOf, ownCli, reachesMail, shellWord } from './launch.js';
|
|
23
|
+
import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
|
|
24
24
|
import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -360,17 +360,37 @@ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which
|
|
|
360
360
|
return found;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
363
|
+
/**
|
|
364
|
+
* The session the book says is in this Orca tab, wherever in the fleet it is,
|
|
365
|
+
* when the caller is that session.
|
|
366
|
+
*
|
|
367
|
+
* A Codex session is the caller only when the kit's launch line started the
|
|
368
|
+
* Codex it runs under: `shell` is the mark that line leaves, and everything the
|
|
369
|
+
* harness runs inherits it. A Codex that Orca brought back by itself has no
|
|
370
|
+
* mark and no `--no-daemon`, so it runs its commands in Codex's shared
|
|
371
|
+
* background server, whose every process carries the tab of whichever session
|
|
372
|
+
* started it (#408). Its tab then says nothing about who is asking. Inside
|
|
373
|
+
* Codex's sandbox `ps` does not run, so the environment is all there is to go
|
|
374
|
+
* on. A Claude session has no such server, and one Orca brought back is still
|
|
375
|
+
* the session in its tab.
|
|
376
|
+
*/
|
|
377
|
+
export function sessionInTab(bots, tab, shell = process.env[SHELL_ENV]) {
|
|
365
378
|
for (const name of botNames(bots)) {
|
|
366
379
|
const book = readBook(botDir(bots, name));
|
|
367
380
|
for (const [session, entry] of Object.entries(book.sessions)) {
|
|
368
|
-
if (entry?.tab
|
|
381
|
+
if (entry?.tab !== tab) continue;
|
|
382
|
+
const found = findSession(bots, `${name}/${session}`);
|
|
383
|
+
if (found.harness === 'codex' && (shell === undefined || shell === '')) throw new Error(notTheCodexInTab(found, tab));
|
|
384
|
+
return found;
|
|
369
385
|
}
|
|
370
386
|
}
|
|
371
387
|
return undefined;
|
|
372
388
|
}
|
|
373
389
|
|
|
390
|
+
/** Why a caller in a Codex session's tab is not taken to be that session, and what puts it right. */
|
|
391
|
+
const notTheCodexInTab = (found, tab) =>
|
|
392
|
+
`this command takes its caller from the Orca tab it runs in, ${tab}, which is ${found.bot}/${found.session}'s, a Codex session; but it did not come from a Codex the kit started there: nothing of the kit's launch line is in its environment. A Codex that Orca brought back by itself, after a restart or an update, runs its commands in Codex's shared background server, under the tab of whichever session started that server, so the kit cannot tell which session is asking. Nothing was done. \`${shellWord(ownCli())} restart --bots ${shellWord(found.bots)} --bot <bot> --session <name>\` starts each such session on the kit's line; \`${shellWord(ownCli())} health --bots ${shellWord(found.bots)}\` names them. If Codex then shows "This conversation is open in another app", Codex's shared background server still holds that conversation, and it can outlive the session that started it: once every such session is back on the kit's line, stop the server with Codex's own \`codex app-server daemon stop\`, then restart the stuck session again. That stops the server for everything using it, Codex sessions outside the kit included, so first check that nothing else needs it.`;
|
|
393
|
+
|
|
374
394
|
/**
|
|
375
395
|
* The message as it will travel: the text itself when it is short enough, and
|
|
376
396
|
* otherwise a file beside the bots folder with a body that names it.
|
|
@@ -442,11 +462,19 @@ function nudge(to, from, subject) {
|
|
|
442
462
|
if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
|
|
443
463
|
if (found.handle === undefined) return { nudged: false };
|
|
444
464
|
|
|
445
|
-
typeIntoTab(
|
|
465
|
+
const sent = typeIntoTab(
|
|
446
466
|
found.handle,
|
|
447
467
|
`Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
|
|
468
|
+
{ watch: true },
|
|
448
469
|
);
|
|
449
|
-
|
|
470
|
+
// Typed is not taken. A harness busy with a turn queues the line and gives
|
|
471
|
+
// it no turn of its own, and a line can be lost, and Orca's receipt looks
|
|
472
|
+
// the same for both: only a turn start it saw says the line landed (#394).
|
|
473
|
+
if (sent?.send?.prompt?.stages?.includes('turn_started')) return { nudged: true };
|
|
474
|
+
// Where Orca did not watch the line at all, as when its own notice has
|
|
475
|
+
// just started a turn in the tab, it saw nothing either way, and says so.
|
|
476
|
+
if (sent?.send?.prompt?.observation === 'unsupported') return { nudged: true, nudgeUnseen: unseen(sent), nudgeWatched: false };
|
|
477
|
+
return { nudged: true, nudgeUnseen: unseen(sent) };
|
|
450
478
|
} catch (error) {
|
|
451
479
|
// The message is already queued, and it is waiting whatever Orca says
|
|
452
480
|
// about the tab. So this is reported rather than thrown: a send that ends
|
|
@@ -457,6 +485,14 @@ function nudge(to, from, subject) {
|
|
|
457
485
|
}
|
|
458
486
|
}
|
|
459
487
|
|
|
488
|
+
/** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
|
|
489
|
+
function unseen(sent) {
|
|
490
|
+
const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
|
|
491
|
+
if (warnings.length > 0) return warnings.join(' ');
|
|
492
|
+
const stages = sent?.send?.prompt?.stages;
|
|
493
|
+
return `Orca's receipt shows ${Array.isArray(stages) ? stages.join(', ') : 'no stages'} and no turn start`;
|
|
494
|
+
}
|
|
495
|
+
|
|
460
496
|
/**
|
|
461
497
|
* The look before the nudge. Orca now and then refuses a handle it has just
|
|
462
498
|
* listed as stale, and a fresh listing hands out one that works (tech notes,
|
|
@@ -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 0031).
|
|
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 0031): `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
|
package/src/orca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Every call the kit makes to Orca goes through here (ADR
|
|
1
|
+
// Every call the kit makes to Orca goes through here (ADR 0031). Orca's CLI
|
|
2
2
|
// changes often, so the kit reads `--json` and never the human text, and keeps
|
|
3
3
|
// the parsing in one place.
|
|
4
4
|
//
|
|
@@ -13,6 +13,9 @@ import { homedir } from 'node:os';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
|
|
16
|
+
import { SHELL_ENV } from './launch.js';
|
|
17
|
+
import { TAB_ENV } from './record.js';
|
|
18
|
+
|
|
16
19
|
// The Orca that works for a normal user: `/usr/local/bin/orca` is a root-only
|
|
17
20
|
// symlink on this machine (tech notes, section 1). OBK_ORCA overrides it, for a
|
|
18
21
|
// machine that keeps Orca somewhere else.
|
|
@@ -40,10 +43,14 @@ export function orca(args, { timeoutMs } = {}) {
|
|
|
40
43
|
/** What an error carries when Orca was given a time and did not answer in it. */
|
|
41
44
|
export const TIMED_OUT = 'orca_timed_out';
|
|
42
45
|
|
|
43
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Orca's refusal as an error, with its code on it, so a caller can tell a stale
|
|
48
|
+
* handle from the rest (#294), and its own message as `reason`, so a caller
|
|
49
|
+
* that goes by the message reads Orca's words rather than the kit's (#405).
|
|
50
|
+
*/
|
|
44
51
|
const refusal = (args, answer) => Object.assign(
|
|
45
52
|
new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`),
|
|
46
|
-
{ code: answer.error?.code },
|
|
53
|
+
{ code: answer.error?.code, reason: answer.error?.message },
|
|
47
54
|
);
|
|
48
55
|
|
|
49
56
|
/** One Orca command, and the whole envelope back, refusals included. */
|
|
@@ -195,7 +202,7 @@ const CLIENT_KILL_MS = 3000;
|
|
|
195
202
|
*
|
|
196
203
|
* Orca's window re-reads only when its runtime says the projects changed, and
|
|
197
204
|
* `setup-update` and `setup-delete` do not say so. `project.update` with no
|
|
198
|
-
* changes does, and Orca's CLI does not offer it (ADR
|
|
205
|
+
* changes does, and Orca's CLI does not offer it (ADR 0031). The caller prints
|
|
199
206
|
* `RELOAD_LINE` either way. It does not take a removed project out of the
|
|
200
207
|
* sidebar: `reloadWindow` does.
|
|
201
208
|
*/
|
|
@@ -206,7 +213,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
|
|
|
206
213
|
* runtime's answer, or undefined when there is none to be had.
|
|
207
214
|
*
|
|
208
215
|
* It goes through Orca's own runtime client out of the installed app, run by
|
|
209
|
-
* Orca's binary the way its `bin/orca` runs its CLI (ADR
|
|
216
|
+
* Orca's binary the way its `bin/orca` runs its CLI (ADR 0031). None of that is
|
|
210
217
|
* Orca's published interface, so anything that goes wrong is a quiet
|
|
211
218
|
* undefined: it never throws and is never tried twice.
|
|
212
219
|
*/
|
|
@@ -243,7 +250,7 @@ const RELOAD_KILL_MS = 5000;
|
|
|
243
250
|
|
|
244
251
|
/**
|
|
245
252
|
* Have Orca's window force-reload itself, after a project was removed: true
|
|
246
|
-
* only when it did (#343, ADR
|
|
253
|
+
* only when it did (#343, ADR 0031).
|
|
247
254
|
*
|
|
248
255
|
* The window keeps a removed project in its sidebar until it is rebuilt, and
|
|
249
256
|
* `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
|
|
@@ -369,6 +376,9 @@ function screenOf(handle) {
|
|
|
369
376
|
/** A row the harness starts with its selection pointer: `›` on Codex, `❯` on Claude Code. */
|
|
370
377
|
const POINTER_ROW = /^ *[›❯]/;
|
|
371
378
|
|
|
379
|
+
/** How a harness's form says it is answered, as Claude Code 2.1.283 draws its foot (#416). */
|
|
380
|
+
const FORM_FOOT = /Enter to (?:continue|confirm|select)\b.*Esc to cancel/i;
|
|
381
|
+
|
|
372
382
|
/** That pointer on a numbered choice, and the column its number starts in. */
|
|
373
383
|
const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
374
384
|
|
|
@@ -376,7 +386,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
376
386
|
* Whether the rows of a rendered screen hold a question of the harness's own.
|
|
377
387
|
*
|
|
378
388
|
* Every one seen is a numbered list of choices with the harness's pointer on
|
|
379
|
-
* one (tech notes, section 1; ADR
|
|
389
|
+
* one (tech notes, section 1; ADR 0031). The same pointer starts the harness's
|
|
380
390
|
* input line and its echo of the user's past turns, and the input line is the
|
|
381
391
|
* lowest of them whenever it is on screen, so only the lowest pointer row is
|
|
382
392
|
* asked about: a question counts while it stands in the input line's place.
|
|
@@ -385,6 +395,13 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
385
395
|
*/
|
|
386
396
|
export function questionIn(rows) {
|
|
387
397
|
const at = rows.findLastIndex((row) => POINTER_ROW.test(row));
|
|
398
|
+
// A form of the harness's own has no numbers but a foot that says how to
|
|
399
|
+
// answer it: Claude Code 2.1.283's "Teach auto mode" ends `Enter to continue
|
|
400
|
+
// · Esc to cancel`, and a return there is Continue, which starts a scan
|
|
401
|
+
// (#416). It counts on or below the lowest pointer row, or anywhere when
|
|
402
|
+
// there is none: under old words of one in the history, the input line is
|
|
403
|
+
// the lowest pointer, and nothing below it is a foot.
|
|
404
|
+
if (rows.slice(Math.max(at, 0)).some((row) => FORM_FOOT.test(row))) return true;
|
|
388
405
|
const pointer = at < 0 ? null : ON_A_CHOICE.exec(rows[at]);
|
|
389
406
|
if (pointer === null) return false;
|
|
390
407
|
const column = pointer[1].length;
|
|
@@ -431,9 +448,12 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
431
448
|
// there. The program in front has to be the agent Orca names. With no name
|
|
432
449
|
// it may be a harness a few seconds into its launch; under another name it
|
|
433
450
|
// may be a pager started after the harness quit, under an identity Orca has
|
|
434
|
-
// not let go of (tech notes, section 1). A harness run
|
|
435
|
-
// as `node`
|
|
436
|
-
|
|
451
|
+
// not let go of (tech notes, section 1). A harness run under another name,
|
|
452
|
+
// such as `node` for an npm install, passes on the kit's own mark instead
|
|
453
|
+
// (#261).
|
|
454
|
+
// Either way Orca has to name an agent there: a harness it has not named yet
|
|
455
|
+
// may be seconds into its launch and not ready for a line.
|
|
456
|
+
if (seen.front === undefined || seen.agent === undefined || (seen.command !== seen.agent && !launchedHere(seen.pid, tabId))) {
|
|
437
457
|
const why = seen.front === undefined
|
|
438
458
|
? seen.unreadable
|
|
439
459
|
: `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
|
|
@@ -449,6 +469,25 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
449
469
|
return { handle: live.handle, agent: seen.agent };
|
|
450
470
|
}
|
|
451
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Whether the process `pid`, in front of the tab `tabId`, is the harness the
|
|
474
|
+
* kit's launch line started there, whatever it is called (#261). The line sets
|
|
475
|
+
* `OBK_TAB_SHELL` to the tab's shell's pid, for the harness alone and not
|
|
476
|
+
* exported, so the harness carries it, with its parent that shell. A program
|
|
477
|
+
* the shell starts later, such as `less` after the harness quit (#232), has no
|
|
478
|
+
* mark. A program the harness starts carries the mark, and its parent is the
|
|
479
|
+
* harness. A tab Orca restored by itself has no mark at all. Only whole words
|
|
480
|
+
* of the environment count, and anything unread is a no.
|
|
481
|
+
*/
|
|
482
|
+
function launchedHere(pid, tabId) {
|
|
483
|
+
if (pid === undefined) return false;
|
|
484
|
+
const words = wordsOfProcess(pid);
|
|
485
|
+
if (words === undefined || !words.includes(`${TAB_ENV}=${tabId}`)) return false;
|
|
486
|
+
const marks = words.filter((word) => new RegExp(`^${SHELL_ENV}=[0-9]+$`).test(word));
|
|
487
|
+
const own = psLine(pid);
|
|
488
|
+
return marks.length === 1 && own !== undefined && Number(marks[0].slice(SHELL_ENV.length + 1)) === own.ppid;
|
|
489
|
+
}
|
|
490
|
+
|
|
452
491
|
/**
|
|
453
492
|
* Who holds the terminal of the tab `handle`, as `frontOf` answers, without
|
|
454
493
|
* waiting on the tab: for a caller that asks about every tab and has nothing
|
|
@@ -475,7 +514,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
475
514
|
*
|
|
476
515
|
* `ps` is asked first. Where it cannot read the tab, as inside Codex's
|
|
477
516
|
* sandbox, where it does not start at all, Orca's runtime is asked instead
|
|
478
|
-
* (#298, ADR
|
|
517
|
+
* (#298, ADR 0031).
|
|
479
518
|
*/
|
|
480
519
|
function frontOf(handle, ptyId) {
|
|
481
520
|
const read = frontByPs(ptyId);
|
|
@@ -511,7 +550,7 @@ function frontByOrca(handle) {
|
|
|
511
550
|
* Who holds the terminal of the pane `ptyId`, as `ps` says it.
|
|
512
551
|
*
|
|
513
552
|
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
514
|
-
* gives that pid's terminal's foreground process group (ADR
|
|
553
|
+
* gives that pid's terminal's foreground process group (ADR 0031).
|
|
515
554
|
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
516
555
|
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
517
556
|
* `diagnostics memory` is a diagnostics command and may change, so everything
|
|
@@ -563,14 +602,25 @@ function psLine(pid) {
|
|
|
563
602
|
/**
|
|
564
603
|
* Close one tab, by the handle Orca issued for it.
|
|
565
604
|
*
|
|
566
|
-
*
|
|
567
|
-
* tab: `--terminal <handle> --tab`. Orca's other
|
|
605
|
+
* With `closeTerminal` below, the only place in the kit that takes anything
|
|
606
|
+
* away, and each takes away one tab: `--terminal <handle> --tab`. Orca's other
|
|
607
|
+
* form, `--worktree <sel> --all`,
|
|
568
608
|
* closes every tab of a project with its layouts and resume records, which are
|
|
569
609
|
* the user's work and not the kit's to end (tech notes, section 1).
|
|
570
610
|
*/
|
|
571
611
|
export const closeTab = (handle) =>
|
|
572
612
|
orca(['terminal', 'close', '--terminal', handle, '--tab']).close;
|
|
573
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Close one terminal without `--tab`: for a tab of one pane, as every tab the
|
|
616
|
+
* kit opens is, Orca then closes that tab by its id and stops its process (read
|
|
617
|
+
* in the 1.4.215 bundle). Only for a tab `--tab` could not find (#405): Orca
|
|
618
|
+
* looks a `--tab` close up in a tab snapshot of its own, which a tab it still
|
|
619
|
+
* lists can be missing from after a restart, and answers `tab_not_found`.
|
|
620
|
+
*/
|
|
621
|
+
export const closeTerminal = (handle) =>
|
|
622
|
+
orca(['terminal', 'close', '--terminal', handle]).close;
|
|
623
|
+
|
|
574
624
|
/**
|
|
575
625
|
* Type `text` into a tab and press return.
|
|
576
626
|
*
|
|
@@ -584,20 +634,28 @@ export const closeTab = (handle) =>
|
|
|
584
634
|
* The wait is short because the caller is telling a session it has mail, not
|
|
585
635
|
* handing it work: mail that has to wait for the next check is a smaller cost
|
|
586
636
|
* than a command that hangs for half a minute.
|
|
637
|
+
*
|
|
638
|
+
* With `watch`, the line is sent with that wait from the start, and Orca's
|
|
639
|
+
* receipt says whether it saw the line start a turn (#394). It gives back
|
|
640
|
+
* Orca's `result`: the receipt is in its `send.prompt`, and Orca's words about
|
|
641
|
+
* a turn it did not see in its `warnings`.
|
|
587
642
|
*/
|
|
588
|
-
export function typeIntoTab(handle, text) {
|
|
643
|
+
export function typeIntoTab(handle, text, { watch = false } = {}) {
|
|
589
644
|
const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
|
|
590
|
-
const answer = ask(args);
|
|
591
|
-
if (answer.ok === true) return answer.result
|
|
645
|
+
const answer = ask(watch ? [...args, '--wait-submit', String(SUBMIT_WAIT_S)] : args);
|
|
646
|
+
if (answer.ok === true) return answer.result;
|
|
592
647
|
|
|
593
648
|
const again = answer.error?.data?.orchestrationRequestId;
|
|
594
649
|
if (answer.error?.code !== 'agent_prompt_blocked' || typeof again !== 'string') {
|
|
595
650
|
throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
|
|
596
651
|
}
|
|
597
|
-
return orca([...args, '--retry-request', again, '--wait-submit', String(SUBMIT_WAIT_S)])
|
|
652
|
+
return orca([...args, '--retry-request', again, '--wait-submit', String(SUBMIT_WAIT_S)]);
|
|
598
653
|
}
|
|
599
654
|
|
|
600
|
-
/**
|
|
655
|
+
/**
|
|
656
|
+
* How long a watched or re-issued line is given to be submitted before Orca
|
|
657
|
+
* gives up on it. An idle harness started its turn in about 2 s, live (#394).
|
|
658
|
+
*/
|
|
601
659
|
const SUBMIT_WAIT_S = 5;
|
|
602
660
|
|
|
603
661
|
/** Where Orca names the terminal a process runs in, in every pane it opens. */
|
package/src/pause.js
CHANGED
|
@@ -14,7 +14,7 @@ import { realpathSync } from 'node:fs';
|
|
|
14
14
|
|
|
15
15
|
import { botDir, markPaused, readBot } from './bot.js';
|
|
16
16
|
import { DAILY_SESSION } from './init.js';
|
|
17
|
-
import { closeTabs, tabsToClose } from './restart.js';
|
|
17
|
+
import { closeTabs, commandLine, tabsToClose } from './restart.js';
|
|
18
18
|
import { BOT_FATHER, botsNamed, bringUp, sessionsOf } from './up.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -30,7 +30,7 @@ export async function pauseSessions(bots, { bot, session }) {
|
|
|
30
30
|
// they were.
|
|
31
31
|
const going = tabsToClose(bots, bot, home, sessions);
|
|
32
32
|
const changed = markPaused(bots, bot, session, true);
|
|
33
|
-
const closed = await closeTabs(home, going, bots, bot);
|
|
33
|
+
const closed = await closeTabs(home, going, bots, bot, commandLine('pause', bots, bot, session));
|
|
34
34
|
return { bot, session, changed, closed };
|
|
35
35
|
}
|
|
36
36
|
|
package/src/restart.js
CHANGED
|
@@ -34,7 +34,7 @@ import { setTimeout as pause } from 'node:timers/promises';
|
|
|
34
34
|
import { bookFile, readBook } from './book.js';
|
|
35
35
|
import { botDir, readBot } from './bot.js';
|
|
36
36
|
import { ownCli, shellWord } from './launch.js';
|
|
37
|
-
import { closeTab, findProject, frontOfTab, tabs } from './orca.js';
|
|
37
|
+
import { closeTab, closeTerminal, findProject, frontOfTab, orcaCli, tabs } from './orca.js';
|
|
38
38
|
import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -68,7 +68,7 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true), { quit: true });
|
|
71
|
-
const closed = await closeTabs(home, going, bots, name);
|
|
71
|
+
const closed = await closeTabs(home, going, bots, name, commandLine('restart', bots, name, onlySession));
|
|
72
72
|
return { closed, ...(await bringUp(bots, { bot: name, session: onlySession })) };
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -113,12 +113,14 @@ export function tabsToClose(bots, name, home, sessions, { keepless = false, quit
|
|
|
113
113
|
/**
|
|
114
114
|
* Close the tabs `tabsToClose` gave, one at a time by their own handles, and
|
|
115
115
|
* wait until Orca's listing agrees they are gone. Returns one entry per tab.
|
|
116
|
+
* `again` is the command that asked, as the user would run it again: what to do
|
|
117
|
+
* once a tab Orca would not close has been closed by hand.
|
|
116
118
|
*/
|
|
117
|
-
export async function closeTabs(home, going, bots, name) {
|
|
119
|
+
export async function closeTabs(home, going, bots, name, again) {
|
|
118
120
|
const closed = [];
|
|
119
121
|
for (const { name: session, tab } of going) {
|
|
120
122
|
try {
|
|
121
|
-
|
|
123
|
+
closeWhole(tab.handle, again);
|
|
122
124
|
} catch (error) {
|
|
123
125
|
// A tab that would not close still holds its session, so nothing is
|
|
124
126
|
// opened after this: two harnesses on one conversation is worse than a
|
|
@@ -132,6 +134,41 @@ export async function closeTabs(home, going, bots, name) {
|
|
|
132
134
|
return closed;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Close one session's tab. Orca finds a `--tab` close in a tab snapshot of its
|
|
139
|
+
* own, which a tab it still lists can be missing from after a restart, and then
|
|
140
|
+
* answers `tab_not_found` (#405, read in the 1.4.215 bundle). The same terminal
|
|
141
|
+
* closed without `--tab` closes its one-pane tab by id, so that is done instead,
|
|
142
|
+
* and `gone` checks the tab went as it does after any close. Every other
|
|
143
|
+
* refusal stops the restart as it did.
|
|
144
|
+
*/
|
|
145
|
+
function closeWhole(handle, again) {
|
|
146
|
+
try {
|
|
147
|
+
closeTab(handle);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (!notFound(error)) throw error;
|
|
150
|
+
try {
|
|
151
|
+
closeTerminal(handle);
|
|
152
|
+
} catch (second) {
|
|
153
|
+
// With the tab closed by hand there is nothing left for the command to
|
|
154
|
+
// close, so running it again finishes it, whichever command it was.
|
|
155
|
+
throw new Error(`${error.message}, and then ${second.message}. Close it by hand, then run the same command again: ${shellWord(orcaCli())} terminal close --terminal ${shellWord(handle)} then ${again}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether Orca refused a `--tab` close for a tab it could not find: its code,
|
|
162
|
+
* or its 1.4.215 form of code `runtime_error` with exactly that message. Orca's
|
|
163
|
+
* own words, never the kit's wrapped text, and no other form (#405).
|
|
164
|
+
*/
|
|
165
|
+
const notFound = (error) =>
|
|
166
|
+
error.code === 'tab_not_found' || (error.code === 'runtime_error' && error.reason === 'tab_not_found');
|
|
167
|
+
|
|
168
|
+
/** A command of the kit's as the user would type it, for a message to name. */
|
|
169
|
+
export const commandLine = (verb, bots, bot, session) =>
|
|
170
|
+
`${shellWord(ownCli())} ${verb} --bots ${shellWord(bots)} --bot ${bot}${session === undefined ? '' : ` --session ${session}`}`;
|
|
171
|
+
|
|
135
172
|
/** How long Orca is given to stop listing a tab it has closed, and how often it is asked. */
|
|
136
173
|
const SETTLED_MS = 5000;
|
|
137
174
|
const ASK_MS = 100;
|
package/src/retire.js
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
// What goes is what the kit made for it: its tabs, closed one at a time by
|
|
10
10
|
// their own handles; its Orca project, after the tabs and never before; its
|
|
11
11
|
// start-prompt files beside the bots folder; and, for a bot, the skill links
|
|
12
|
-
// the kit made in its folder. A
|
|
12
|
+
// the kit made in its folder. A prompt file that cannot be removed, as under a
|
|
13
|
+
// harness's sandbox that may not write beside the bots folder, does not stop a
|
|
14
|
+
// retire halfway: nothing reads it once its session is gone, so the retire
|
|
15
|
+
// finishes and names it, with how to remove it (#393). A tab the book does not name is the user's, so a
|
|
13
16
|
// bot whose project holds one is not retired until they have dealt with it.
|
|
14
17
|
//
|
|
15
18
|
// Closing a tab here ends the conversation in it on purpose, so unlike a
|
|
16
19
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
17
|
-
// The mailbox Runs stay: Orca has no way to remove one (ADR
|
|
20
|
+
// The mailbox Runs stay: Orca has no way to remove one (ADR 0030).
|
|
18
21
|
|
|
19
22
|
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
20
23
|
import path from 'node:path';
|
|
@@ -23,7 +26,7 @@ import { readBook, tabIdsIn, updateBook } from './book.js';
|
|
|
23
26
|
import { botDir, dropSession, readBot } from './bot.js';
|
|
24
27
|
import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
|
|
25
28
|
import { fleetMember } from './pause.js';
|
|
26
|
-
import { closeTabs, tabsToClose } from './restart.js';
|
|
29
|
+
import { closeTabs, commandLine, tabsToClose } from './restart.js';
|
|
27
30
|
import { unlinkSkills } from './skills.js';
|
|
28
31
|
import { promptPath, sessionsOf } from './up.js';
|
|
29
32
|
|
|
@@ -31,13 +34,14 @@ import { promptPath, sessionsOf } from './up.js';
|
|
|
31
34
|
export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
|
-
* Retire the session `session` of the bot `bot`. Returns `{ bot, session,
|
|
37
|
+
* Retire the session `session` of the bot `bot`. Returns `{ bot, session,
|
|
38
|
+
* closed }`, and `promptsLeft` when its prompt file could not be removed.
|
|
35
39
|
*/
|
|
36
40
|
export async function retireSession(bots, { bot, session }) {
|
|
37
41
|
const home = fleetMember(bots, bot, 'retire', session);
|
|
38
42
|
const sessions = sessionsOf(readBot(home, bot), session);
|
|
39
43
|
|
|
40
|
-
const closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot);
|
|
44
|
+
const closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
|
|
41
45
|
dropSession(bots, bot, session);
|
|
42
46
|
|
|
43
47
|
const at = new Date().toISOString();
|
|
@@ -47,15 +51,16 @@ export async function retireSession(bots, { bot, session }) {
|
|
|
47
51
|
delete book.sessions[session];
|
|
48
52
|
book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
|
|
49
53
|
});
|
|
50
|
-
|
|
54
|
+
const left = removePrompts([promptPath(bots, bot, session)]);
|
|
51
55
|
|
|
52
|
-
return { bot, session, closed };
|
|
56
|
+
return { bot, session, closed, ...left };
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* Retire the bot `bot`. Returns `{ bot, closed, project, windowReloaded, moved }`:
|
|
57
61
|
* the tabs it closed, the Orca project it took away (if it had one), whether
|
|
58
|
-
* Orca's window was reloaded after that, and where the bot is now
|
|
62
|
+
* Orca's window was reloaded after that, and where the bot is now; and
|
|
63
|
+
* `promptsLeft` when a prompt file could not be removed.
|
|
59
64
|
* When Orca does not confirm the project gone, it returns `{ bot, closed,
|
|
60
65
|
* project, trouble }` instead, and the bot is left where it was.
|
|
61
66
|
*/
|
|
@@ -81,7 +86,7 @@ export async function retireBot(bots, { bot }) {
|
|
|
81
86
|
// taken away with one of them still open. `closeTabs` waits until Orca agrees
|
|
82
87
|
// they are gone.
|
|
83
88
|
const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
|
|
84
|
-
const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot);
|
|
89
|
+
const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot, commandLine('retire', bots, bot));
|
|
85
90
|
let windowReloaded;
|
|
86
91
|
if (project !== undefined) {
|
|
87
92
|
deleteProject(project.id);
|
|
@@ -103,12 +108,28 @@ export async function retireBot(bots, { bot }) {
|
|
|
103
108
|
windowReloaded = reloadWindow();
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
111
|
+
const names = new Set([...known.sessions, ...booked].map((session) => session.name));
|
|
112
|
+
const left = removePrompts([...names].map((name) => promptPath(bots, bot, name)));
|
|
109
113
|
unlinkSkills(home);
|
|
110
114
|
mkdirSync(retiredDir(bots), { recursive: true });
|
|
111
115
|
renameSync(botDir(bots, bot), moved);
|
|
112
116
|
|
|
113
|
-
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved };
|
|
117
|
+
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved, ...left };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove start-prompt files, and say which could not be: `{ promptsLeft: [{
|
|
122
|
+
* file, reason }] }`, or nothing when every one went. One that is not there is
|
|
123
|
+
* nothing to remove.
|
|
124
|
+
*/
|
|
125
|
+
function removePrompts(files) {
|
|
126
|
+
const promptsLeft = files.flatMap((file) => {
|
|
127
|
+
try {
|
|
128
|
+
rmSync(file, { force: true });
|
|
129
|
+
return [];
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return [{ file, reason: error.message }];
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return promptsLeft.length === 0 ? {} : { promptsLeft };
|
|
114
135
|
}
|
package/src/temp.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `obk temp make` and `obk temp retire`: a session's own temporary sessions
|
|
2
|
+
// (PRD 6.4, #227).
|
|
3
|
+
//
|
|
4
|
+
// Any long-lived session can make a temporary session of its own bot for a
|
|
5
|
+
// piece of work, and retire it when the work is done, without Bot Father, who
|
|
6
|
+
// stays the manager of long-lived sessions. A temporary session is an ordinary
|
|
7
|
+
// session in every other way: in bot.yaml, brought up with a tab and a mailbox,
|
|
8
|
+
// its conversations kept in the book. What makes it temporary is one field of
|
|
9
|
+
// its book entry, `temporary: { maker, made }`, and the book is the one place
|
|
10
|
+
// that says so (ADR 0012).
|
|
11
|
+
//
|
|
12
|
+
// The caller is the session whose tab this runs in, as the book records it: the
|
|
13
|
+
// tab says who is calling now. The maker is kept by its session name, not its
|
|
14
|
+
// tab, so it still owns what it made after a restart gives it a new tab.
|
|
15
|
+
//
|
|
16
|
+
// Everything that can refuse is checked before anything is written, so a
|
|
17
|
+
// refusal leaves bot.yaml, the book and Orca as they were. The one write that
|
|
18
|
+
// may fall outside what a harness's sandbox lets a session write, its start
|
|
19
|
+
// prompt's file beside the bots folder, is made first, the way #383 puts the
|
|
20
|
+
// risky write before bot.yaml: refused there, nothing else has changed, and the
|
|
21
|
+
// same command run again with that access goes through.
|
|
22
|
+
|
|
23
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { readBook, updateBook } from './book.js';
|
|
27
|
+
import { addSession, dropSession, NAME, readBot } from './bot.js';
|
|
28
|
+
import { isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
29
|
+
import { sessionInTab } from './message.js';
|
|
30
|
+
import { retireSession } from './retire.js';
|
|
31
|
+
import { bringUp, promptPath } from './up.js';
|
|
32
|
+
|
|
33
|
+
/** The settings a temporary session takes from its maker unless told otherwise. */
|
|
34
|
+
const INHERITED = ['model', 'effort', 'context', 'approval'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Make a temporary session of the caller's own bot and bring it up. `given`
|
|
38
|
+
* holds what the caller said: `name`, `prompt` or `prompt_file`, and any of
|
|
39
|
+
* `harness` and the inherited settings. Returns `{ bot, session, maker,
|
|
40
|
+
* settings, up }`, where `up` is what bringing it up answered.
|
|
41
|
+
*/
|
|
42
|
+
export async function makeTemp(bots, { tab, ...given }) {
|
|
43
|
+
const caller = callerIn(bots, tab, 'make');
|
|
44
|
+
if (caller.temporary !== undefined) {
|
|
45
|
+
throw new Error(`${caller.bot}/${caller.session} is a temporary session, and only a long-lived session makes one. Nothing was made.`);
|
|
46
|
+
}
|
|
47
|
+
// The name is a folder under work/ and a file beside the bots folder, so it
|
|
48
|
+
// is held to the rule a bot's name is: one plain name, never a path.
|
|
49
|
+
if (!NAME.test(given.name)) {
|
|
50
|
+
throw new Error(`${given.name} cannot be a session's name: a name is lower-case letters, digits and single hyphens, such as review-250. Nothing was made.`);
|
|
51
|
+
}
|
|
52
|
+
if (given.prompt === undefined && given.prompt_file === undefined) {
|
|
53
|
+
throw new Error(`temp make needs the task: --prompt <text> or --prompt-file <path>. Nothing was made.`);
|
|
54
|
+
}
|
|
55
|
+
if (given.prompt !== undefined && given.prompt_file !== undefined) {
|
|
56
|
+
throw new Error(`temp make was given the task twice, as --prompt and as --prompt-file, and a session is told its duty once. Give it one way. Nothing was made.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const bot = readBot(caller.home, caller.bot);
|
|
60
|
+
const maker = bot.sessions.find((session) => session.name === caller.session);
|
|
61
|
+
const settings = { name: given.name };
|
|
62
|
+
const harness = given.harness ?? maker?.harness;
|
|
63
|
+
if (harness !== undefined) settings.harness = harness;
|
|
64
|
+
for (const field of INHERITED) {
|
|
65
|
+
const value = given[field] ?? maker?.[field];
|
|
66
|
+
if (value !== undefined) settings[field] = value;
|
|
67
|
+
}
|
|
68
|
+
if (given.prompt !== undefined) settings.prompt = given.prompt;
|
|
69
|
+
else settings.prompt_file = given.prompt_file;
|
|
70
|
+
settings.work_dir = `work/${given.name}`;
|
|
71
|
+
|
|
72
|
+
const written = bot.sessions.some((session) => session.name === given.name) ? undefined : writePromptFirst(bots, caller, settings);
|
|
73
|
+
// Refuses a name the bot already has, and a setting that will not work,
|
|
74
|
+
// before it writes anything; the prompt written first goes with a refusal.
|
|
75
|
+
let added;
|
|
76
|
+
try {
|
|
77
|
+
added = addSession(bots, caller.bot, settings);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (written !== undefined) rmSync(written, { force: true });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const made = new Date().toISOString();
|
|
83
|
+
try {
|
|
84
|
+
await updateBook(caller.home, (book) => {
|
|
85
|
+
book.sessions[given.name] = { ...book.sessions[given.name], temporary: { maker: caller.session, made } };
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A session in bot.yaml that the book does not call temporary would be a
|
|
89
|
+
// long-lived one to everything that reads it, its maker's retire included,
|
|
90
|
+
// so it is taken back off rather than left that way.
|
|
91
|
+
const failed = `${caller.bot}/${given.name} could not be recorded in the book as temporary (${error.message})`;
|
|
92
|
+
try {
|
|
93
|
+
dropSession(bots, caller.bot, given.name);
|
|
94
|
+
} catch (undo) {
|
|
95
|
+
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}`);
|
|
96
|
+
}
|
|
97
|
+
// Off bot.yaml, nothing is made; a prompt file that stays is only a file,
|
|
98
|
+
// and nothing reads it (#393).
|
|
99
|
+
try {
|
|
100
|
+
if (written !== undefined) rmSync(written, { force: true });
|
|
101
|
+
} catch (left) {
|
|
102
|
+
throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Run this again once the book can be written.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let up;
|
|
108
|
+
try {
|
|
109
|
+
up = await bringUp(bots, { bot: caller.bot, session: given.name });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const cli = shellWord(ownCli());
|
|
112
|
+
throw new Error(`${caller.bot}/${given.name} is made, in bot.yaml and in the book as temporary with its maker ${caller.session}, and could not be brought up: ${error.message} Bring it up with ${cli} up --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name} or retire it with ${cli} temp retire --bots ${shellWord(bots)} --name ${given.name}`);
|
|
113
|
+
}
|
|
114
|
+
return { bot: caller.bot, session: given.name, maker: caller.session, settings: added.session, up };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Write the start prompt's file where bringing the session up will write it,
|
|
119
|
+
* when its prompt goes to the harness out of a file at all, and say where. A
|
|
120
|
+
* prompt that cannot be read yet is left to `addSession`, which refuses it in
|
|
121
|
+
* its own words.
|
|
122
|
+
*/
|
|
123
|
+
function writePromptFirst(bots, caller, settings) {
|
|
124
|
+
let prompt;
|
|
125
|
+
try {
|
|
126
|
+
prompt = startPrompt(settings, { home: caller.home, workDir: workDirOf(settings, caller.home) });
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
if (isShortPrompt(prompt)) return undefined;
|
|
131
|
+
|
|
132
|
+
const file = promptPath(bots, caller.bot, settings.name);
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
135
|
+
writeFileSync(file, prompt);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`${caller.bot}/${settings.name}'s start prompt could not be written to ${file} (${error.message}), and it is written first, so nothing was made. Run this again where that folder can be written: from a harness's sandbox, with the access to write it.`);
|
|
138
|
+
}
|
|
139
|
+
return file;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Retire a temporary session the caller made. Returns what `retireSession`
|
|
144
|
+
* does, with the maker.
|
|
145
|
+
*/
|
|
146
|
+
export async function retireTemp(bots, { tab, name }) {
|
|
147
|
+
const caller = callerIn(bots, tab, 'retire');
|
|
148
|
+
const bot = readBot(caller.home, caller.bot);
|
|
149
|
+
if (!bot.sessions.some((session) => session.name === name)) {
|
|
150
|
+
throw new Error(`${caller.bot} has no session called ${name}, so there is nothing of yours to retire. Nothing was retired.`);
|
|
151
|
+
}
|
|
152
|
+
const temporary = readBook(caller.home).sessions[name]?.temporary;
|
|
153
|
+
if (temporary === undefined) {
|
|
154
|
+
throw new Error(`${caller.bot}/${name} is a long-lived session, and those are Bot Father's to retire. Nothing was retired.`);
|
|
155
|
+
}
|
|
156
|
+
if (temporary.maker !== caller.session) {
|
|
157
|
+
throw new Error(`${caller.bot}/${name} is a temporary session ${temporary.maker} made, not ${caller.session}, so it is ${temporary.maker}'s to retire. Nothing was retired.`);
|
|
158
|
+
}
|
|
159
|
+
return { ...(await retireSession(bots, { bot: caller.bot, session: name })), maker: caller.session };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The session this runs in, as the book knows it, with its home and whether it
|
|
164
|
+
* is itself temporary.
|
|
165
|
+
*/
|
|
166
|
+
function callerIn(bots, tab, what) {
|
|
167
|
+
const found = tab === undefined ? undefined : sessionInTab(bots, tab);
|
|
168
|
+
if (found === undefined) {
|
|
169
|
+
throw new Error(`temp ${what} is run by a session in its own tab, and this is not one of the fleet's tabs, so there is no session here to ${what} it for. Nothing was done.`);
|
|
170
|
+
}
|
|
171
|
+
const entry = readBook(found.home).sessions[found.session] ?? {};
|
|
172
|
+
return { bot: found.bot, session: found.session, home: found.home, temporary: entry.temporary };
|
|
173
|
+
}
|
package/src/up.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
10
10
|
|
|
11
11
|
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeMailboxTurn, updateBook, withUnclaimed } from './book.js';
|
|
12
|
-
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
12
|
+
import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
|
|
13
13
|
import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
15
15
|
import { writePermissions } from './permissions.js';
|
|
@@ -211,7 +211,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
|
|
|
211
211
|
// Orca is asked first and the book is written after: nothing that takes time
|
|
212
212
|
// happens while the book is held, because a session's own hook may be writing
|
|
213
213
|
// its id into that same file at any moment.
|
|
214
|
-
const { change, ...orca } = orcaProject(home,
|
|
214
|
+
const { change, ...orca } = orcaProject(home, projectName(bots, name));
|
|
215
215
|
await updateBook(home, (book) => { book.orca = orca; });
|
|
216
216
|
// Orca's window does not see a project made or renamed on the command line
|
|
217
217
|
// until it is told (#224).
|