@assuredloop/orca-bot-kit 0.26.1 → 0.28.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/src/bot.js CHANGED
@@ -7,13 +7,14 @@
7
7
  // The files are the user's. A command that cannot do what was asked refuses and
8
8
  // writes nothing, rather than leave a bot half made or a bot.yaml half edited.
9
9
 
10
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, writeFileSync } from 'node:fs';
10
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { isDeepStrictEqual } from 'node:util';
14
14
  import { isSeq, parse, parseDocument, stringify } from 'yaml';
15
15
 
16
- import { DEFAULT_APPROVAL, HARNESSES, harnessOf, sessionTrouble } from './launch.js';
16
+ import { takeBotTurn } from './book.js';
17
+ import { defaultApproval, HARNESSES, harnessOf, ownCli, refuseApprovalArgs, sessionTrouble, set, shellWord, workDirOf } from './launch.js';
17
18
 
18
19
  /** Where the bots live inside the user's bots folder. */
19
20
  export const botsDir = (bots) => path.join(bots, 'bots');
@@ -75,10 +76,21 @@ export function leadsOutside(home, target) {
75
76
  }
76
77
  // Links that never settle lead nowhere the kit can vouch for.
77
78
  if (real === undefined) return `${target}, through links that never settle`;
78
- const root = realpathSync(home);
79
+ const root = realpathSync.native(home);
79
80
  return real === root || real.startsWith(root + path.sep) ? undefined : real;
80
81
  }
81
82
 
83
+ /**
84
+ * Refuse to write a bot's own file — its `bot.yaml`, its `AGENTS.md` — through a
85
+ * link that takes it out of the bot folder at `home`: what is there is the
86
+ * user's own file, not the bot's (#569).
87
+ */
88
+ export function refuseOutside(home, file) {
89
+ const real = leadsOutside(home, file);
90
+ if (real === undefined) return;
91
+ throw new Error(`${file} leads outside the bot folder, to ${real}, through a link, and the kit writes a bot's files only inside the bot folder, so nothing was written. Replace the link with a file of the bot's own, then run the command again.`);
92
+ }
93
+
82
94
  /** How many links deep a path is followed before it is taken to lead nowhere. */
83
95
  const HOPS = 40;
84
96
 
@@ -99,10 +111,16 @@ function whereItLeads(target) {
99
111
  at = path.dirname(at);
100
112
  }
101
113
  try {
102
- return path.join(realpathSync(at), ...rest);
114
+ // The native call, which asks the file system: the JS one runs
115
+ // path.resolve first, which takes `hop/..` away before `hop` is followed.
116
+ return path.join(realpathSync.native(at), ...rest);
103
117
  } catch (error) {
104
118
  if (!lstatSync(at).isSymbolicLink()) throw error;
105
- next = path.join(path.resolve(path.dirname(at), readlinkSync(at)), ...rest);
119
+ // Joined as text, not with path.join or path.resolve: they take `hop/..`
120
+ // away before `hop` is followed, and the file system follows it first
121
+ // (#569 review).
122
+ const text = readlinkSync(at);
123
+ next = [path.isAbsolute(text) ? text : `${path.dirname(at)}${path.sep}${text}`, ...rest].join(path.sep);
106
124
  }
107
125
  }
108
126
  return undefined;
@@ -162,7 +180,7 @@ export function readBot(home, name = path.basename(home)) {
162
180
  // The roles its temporary sessions can be made in, judged by tempRoles.
163
181
  ...(bot.temp_roles === undefined ? {} : { temp_roles: bot.temp_roles }),
164
182
  // The widest approval its temporary sessions may be made at, the user's yes
165
- // kept by `permission approval --temps` (ADR 0041).
183
+ // kept by `permission approval --temps` (ADR 0051).
166
184
  ...(bot.temp_approval === undefined ? {} : { temp_approval: bot.temp_approval }),
167
185
  // A paused bot is one `obk up` leaves closed; its sessions keep their book.
168
186
  ...(bot.paused === true ? { paused: true } : {}),
@@ -182,9 +200,11 @@ export function readBot(home, name = path.basename(home)) {
182
200
  *
183
201
  * The bot's own files, and no more: its `AGENTS.md` is built from this
184
202
  * `bot.yaml` and the rule units, which is the rules build's job and is reported
185
- * as one (PRD 6.6).
203
+ * as one (PRD 6.6). `allow` is the kit's default rules for it, written in the
204
+ * same first write, so that no later read and write of the file can lose a
205
+ * change another command makes at the same time (#595).
186
206
  */
187
- export function createBot(bots, { name, harness, charter }) {
207
+ export function createBot(bots, { name, harness, charter, allow = [] }) {
188
208
  if (!NAME.test(name)) {
189
209
  throw new Error(`${name} cannot be a bot's name: a name is lower-case letters, digits and single hyphens, such as api-bot.`);
190
210
  }
@@ -201,7 +221,7 @@ export function createBot(bots, { name, harness, charter }) {
201
221
 
202
222
  const text = charter?.trim() ? `${charter.trim()}\n` : PLACEHOLDER_CHARTER(name);
203
223
  const files = [
204
- [BOT_YAML, botYaml(name, harness, text)],
224
+ [BOT_YAML, botYaml(name, harness, text, allow)],
205
225
  ['.gitignore', BOT_GITIGNORE],
206
226
  ];
207
227
 
@@ -232,35 +252,39 @@ export function addSession(bots, bot, settings) {
232
252
  if (!existsSync(file)) {
233
253
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
234
254
  }
255
+ refuseOutside(home, file);
235
256
 
236
- const known = readBot(home, bot);
237
- if (known.sessions.some((session) => session.name === settings.name)) {
238
- throw new Error(`${bot} already has a session called ${settings.name}, and session add never writes over one.`);
239
- }
240
-
241
- const session = ordered({ ...settings, approval: settings.approval ?? DEFAULT_APPROVAL });
242
- const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
243
- if (trouble !== undefined) throw new Error(trouble);
257
+ return withBotTurn(home, () => {
258
+ const known = readBot(home, bot);
259
+ if (known.sessions.some((session) => session.name === settings.name)) {
260
+ throw new Error(`${bot} already has a session called ${settings.name}, and session add never writes over one.`);
261
+ }
244
262
 
245
- const source = readFileSync(file, 'utf8');
246
- const doc = parseDocument(source);
247
- const sessions = doc.get('sessions', true);
248
- if (sessions?.items === undefined) {
249
- // No list to add to: an empty `sessions:`, or no sessions key at all.
250
- doc.set('sessions', [session]);
251
- } else {
252
- // A list written inline is written out as a block list from here on, which
253
- // is the one shape a session with a prompt in it reads well in.
254
- sessions.flow = false;
255
- doc.addIn(['sessions'], session);
256
- }
263
+ // Pi has one level, and the session says so in bot.yaml (#547).
264
+ const session = ordered({ ...settings, approval: settings.approval ?? defaultApproval(harnessOf(settings, known.harness)) });
265
+ const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
266
+ if (trouble !== undefined) throw new Error(trouble);
267
+
268
+ const source = readFileSync(file, 'utf8');
269
+ const doc = parseDocument(source);
270
+ const sessions = doc.get('sessions', true);
271
+ if (sessions?.items === undefined) {
272
+ // No list to add to: an empty `sessions:`, or no sessions key at all.
273
+ doc.set('sessions', [session]);
274
+ } else {
275
+ // A list written inline is written out as a block list from here on, which
276
+ // is the one shape a session with a prompt in it reads well in.
277
+ sessions.flow = false;
278
+ doc.addIn(['sessions'], session);
279
+ }
257
280
 
258
- const text = doc.toString(YAML_OUT);
259
- if (!changesExactly(source, text, (was) => ({ ...was, sessions: [...(was.sessions ?? []), session] }))) {
260
- throw new Error(`${file} cannot have a session added to it without changing something else in it, so nothing was written. Add ${settings.name} to its sessions list by hand.`);
261
- }
262
- writeFileSync(file, text);
263
- return { bot, home, session };
281
+ const text = doc.toString(YAML_OUT);
282
+ if (!changesExactly(source, text, (was) => ({ ...was, sessions: [...(was.sessions ?? []), session] }))) {
283
+ throw new Error(`${file} cannot have a session added to it without changing something else in it, so nothing was written. Add ${settings.name} to its sessions list by hand.`);
284
+ }
285
+ writeWhole(file, text);
286
+ return { bot, home, session };
287
+ });
264
288
  }
265
289
 
266
290
  /**
@@ -274,8 +298,10 @@ export function changeBot(bots, bot, { charter }) {
274
298
  if (charter.trim() === '') throw new Error('--charter is empty, and a bot with no charter has no boundary to act inside. Give it one.');
275
299
 
276
300
  const text = `${charter.trim()}\n`;
277
- editBot(bots, bot, `give ${bot} a new charter`, (doc) => doc.set('charter', text), (was) => ({ ...was, charter: text }));
278
- return { bot, home: botDir(bots, bot), charter: text };
301
+ return withBotTurn(existingBot(bots, bot), () => {
302
+ editBot(bots, bot, `give ${bot} a new charter`, (doc) => doc.set('charter', text), (was) => ({ ...was, charter: text }));
303
+ return { bot, home: botDir(bots, bot), charter: text };
304
+ });
279
305
  }
280
306
 
281
307
  /**
@@ -306,13 +332,15 @@ export function allowedNow(bots, bot, rules, flag = '--rule') {
306
332
  * false it only refuses what the edit would refuse, and writes nothing.
307
333
  */
308
334
  export function allowRules(bots, bot, rules, { write = true } = {}) {
309
- const { home, was } = allowedNow(bots, bot, rules);
310
- const added = [...new Set(rules)].filter((rule) => !was.includes(rule));
311
- const allow = [...was, ...added];
312
- if (added.length > 0) {
313
- editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
314
- }
315
- return { bot, home, allow, added };
335
+ return withBotTurn(existingBot(bots, bot), () => {
336
+ const { home, was } = allowedNow(bots, bot, rules);
337
+ const added = [...new Set(rules)].filter((rule) => !was.includes(rule));
338
+ const allow = [...was, ...added];
339
+ if (added.length > 0) {
340
+ editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
341
+ }
342
+ return { bot, home, allow, added };
343
+ });
316
344
  }
317
345
 
318
346
  /**
@@ -323,21 +351,29 @@ export function allowRules(bots, bot, rules, { write = true } = {}) {
323
351
  * false it only refuses what the edit would refuse, and writes nothing.
324
352
  */
325
353
  export function disallowRules(bots, bot, rules, { write = true } = {}) {
326
- const { home, was } = allowedNow(bots, bot, rules);
327
- const disallowed = [...new Set(rules)];
328
- const allow = was.filter((rule) => !disallowed.includes(rule));
329
- editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
330
- return { bot, home, allow, disallowed };
354
+ return withBotTurn(existingBot(bots, bot), () => {
355
+ const { home, was } = allowedNow(bots, bot, rules);
356
+ const disallowed = [...new Set(rules)];
357
+ const allow = was.filter((rule) => !disallowed.includes(rule));
358
+ editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
359
+ return { bot, home, allow, disallowed };
360
+ });
331
361
  }
332
362
 
333
363
  /**
334
364
  * Change the settings of the session `name` of the bot `bot`. Returns
335
- * { bot, home, session } — the session as it now reads.
365
+ * { bot, home, session, moved } — the session as it now reads, and, when it
366
+ * moved to another harness, `{ from, to, defaults }`.
336
367
  *
337
368
  * A setting given replaces the one there, and one given empty is taken out,
338
369
  * which leaves it to the harness's own default. A start prompt is written one
339
370
  * way or the other, so giving one takes the other away. The result is held to
340
371
  * the rules `session add` holds a new session to.
372
+ *
373
+ * A move to another harness takes out the model, effort and context the
374
+ * session had, which belong to the harness it is leaving, unless the same call
375
+ * gives them (#565). The book is not touched: the session's conversation is
376
+ * set aside when it next starts on the new harness.
341
377
  */
342
378
  export function changeSession(bots, bot, name, settings) {
343
379
  const changes = { ...settings };
@@ -347,26 +383,69 @@ export function changeSession(bots, bot, name, settings) {
347
383
  if (changes.prompt !== undefined && changes.prompt_file === undefined) changes.prompt_file = '';
348
384
  if (changes.prompt_file !== undefined && changes.prompt === undefined) changes.prompt = '';
349
385
 
350
- const { home, known, index } = sessionAt(bots, bot, name);
351
- const was = known.sessions[index];
352
- // Every key the entry had stays, a pause mark or one of the user's own among
353
- // them; only the settings given change.
354
- const session = Object.fromEntries(Object.entries({ ...was, ...changes })
355
- .filter(([, value]) => value !== '' && !(Array.isArray(value) && value.every((one) => one === ''))));
356
- const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
357
- if (trouble !== undefined) throw new Error(trouble);
386
+ return withBotTurn(existingBot(bots, bot), () => {
387
+ const { home, known, index } = sessionAt(bots, bot, name);
388
+ const was = known.sessions[index];
389
+ const from = harnessOf(was, known.harness);
390
+ let moved;
391
+ if (changes.harness !== undefined && changes.harness !== from) {
392
+ const defaults = ['model', 'effort', 'context'].filter((key) => changes[key] === undefined && ![undefined, null, ''].includes(was[key]));
393
+ for (const key of defaults) changes[key] = '';
394
+ moved = { from, to: changes.harness, defaults };
395
+ }
396
+ // Every key the entry had stays, a pause mark or one of the user's own among
397
+ // them; only the settings given change.
398
+ const session = Object.fromEntries(Object.entries({ ...was, ...changes })
399
+ .filter(([, value]) => value !== '' && !(Array.isArray(value) && value.every((one) => one === ''))));
400
+ const trouble = sessionTrouble(session, harnessOf(session, known.harness), home);
401
+ if (trouble !== undefined) throw new Error(trouble);
402
+ // A session with no approval runs at its harness's default, auto on claude
403
+ // and codex, and dangerously-skip on Pi: a move to Pi would widen it with
404
+ // nobody asked (ADR 0051, #589).
405
+ if (moved?.to === 'pi' && !set(session.approval) && defaultApproval(from) !== 'dangerously-skip') {
406
+ throw new Error(`${bot}'s session ${name} has no approval in bot.yaml, so on ${from} it runs at ${defaultApproval(from)}. Every Pi session runs as dangerously-skip, and session change widens no approval. After the user's yes, Bot Father sets it with ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${bot} --session ${name} --approval dangerously-skip; then the session can move to Pi. Nothing was changed.`);
407
+ }
408
+ // Judged on the session as it is written, which is the whole session, so a
409
+ // change another command made in between cannot be combined with this one.
410
+ refuseCodexOutside(bot, session, was, known.harness, home, 'session change');
411
+ // The same for an extra argument that sets approval on the session's
412
+ // harness: the caller's check read bot.yaml before this turn (#589).
413
+ if (changes.harness !== undefined || changes.extra_args !== undefined) {
414
+ refuseApprovalArgs(harnessOf(session, known.harness), session.extra_args);
415
+ }
358
416
 
359
- editSession(bots, bot, index, session, `change ${bot}'s session ${name}`);
360
- return { bot, home, session };
417
+ editSession(bots, bot, index, session, `change ${bot}'s session ${name}`);
418
+ return { bot, home, session, ...(moved === undefined ? {} : { moved }) };
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Refuse a session that would run on Codex with a work dir outside its bot
424
+ * home (ADR 0045): Codex is launched with `--add-dir` for it, which lets the
425
+ * session write there, and `session add` and `session change` were in every
426
+ * bot's default set (#585), and a bot may still hold their rules (#615). Outside is by the spelling, which the launch line
427
+ * names, or by where links really lead. `was` is the session before the
428
+ * change: one already on Codex with the same work dir widens nothing.
429
+ */
430
+ export function refuseCodexOutside(bot, session, was, botHarness, home, command) {
431
+ if (harnessOf(session, botHarness) !== 'codex') return;
432
+ const workDir = workDirOf(session, home);
433
+ if (workDir === undefined) return;
434
+ const spelledIn = workDir === home || workDir.startsWith(home + path.sep);
435
+ if (spelledIn && leadsOutside(home, workDir) === undefined) return;
436
+ if (was !== undefined && harnessOf(was, botHarness) === 'codex' && workDirOf(was, home) === workDir) return;
437
+ throw new Error(`${command} would give ${bot}/${session.name}, a Codex session, the work dir ${workDir}, which is outside ${bot}'s folder, ${home}. Codex would then be allowed to write that folder. A Codex session's work dir stays in its bot folder, such as work/${session.name}. Nothing was changed.`);
361
438
  }
362
439
 
363
440
  /**
364
441
  * Record the widest approval the bot's temporary sessions may be made at,
365
- * `level`, as `temp_approval` (ADR 0041). Returns { bot, home, level }.
442
+ * `level`, as `temp_approval` (ADR 0051). Returns { bot, home, level }.
366
443
  */
367
444
  export function setTempApproval(bots, bot, level) {
368
- editBot(bots, bot, `let ${bot}'s temporary sessions be made at ${level}`, (doc) => doc.set('temp_approval', level), (was) => ({ ...was, temp_approval: level }));
369
- return { bot, home: botDir(bots, bot), level };
445
+ return withBotTurn(existingBot(bots, bot), () => {
446
+ editBot(bots, bot, `let ${bot}'s temporary sessions be made at ${level}`, (doc) => doc.set('temp_approval', level), (was) => ({ ...was, temp_approval: level }));
447
+ return { bot, home: botDir(bots, bot), level };
448
+ });
370
449
  }
371
450
 
372
451
  /**
@@ -379,37 +458,39 @@ export function setTempApproval(bots, bot, level) {
379
458
  */
380
459
  export function setRoleCaps(bots, bot, caps, { write = true } = {}) {
381
460
  const home = existingBot(bots, bot);
382
- const roles = readBot(home, bot).temp_roles;
383
- const file = path.join(home, BOT_YAML);
384
- for (const [role] of caps) {
385
- if (roles === null || typeof roles !== 'object' || Array.isArray(roles) || !Object.hasOwn(roles, role)) {
386
- const has = roles !== null && typeof roles === 'object' && !Array.isArray(roles) ? Object.keys(roles) : [];
387
- throw new Error(`${bot} has no temporary-session role called ${role} in ${file}${has.length === 0 ? ', and no temp_roles at all' : `. It has: ${has.join(', ')}`}. Nothing was changed.`);
388
- }
389
- }
390
- const capped = (written, cap) => {
391
- const { cap: was, ...rest } = Array.isArray(written) ? { options: written } : written;
392
- return cap === undefined ? rest : { ...rest, cap };
393
- };
394
- editBot(bots, bot, `set the caps of ${caps.map(([role]) => role).join(', ')}`, (doc) => {
395
- for (const [role, cap] of caps) {
396
- const node = doc.getIn(['temp_roles', role], true);
397
- if (isSeq(node)) {
398
- doc.setIn(['temp_roles', role], doc.createNode(capped(node.toJSON(), cap)));
399
- } else if (cap === undefined) {
400
- doc.deleteIn(['temp_roles', role, 'cap']);
401
- } else {
402
- doc.setIn(['temp_roles', role, 'cap'], cap);
461
+ return withBotTurn(home, () => {
462
+ const roles = readBot(home, bot).temp_roles;
463
+ const file = path.join(home, BOT_YAML);
464
+ for (const [role] of caps) {
465
+ if (roles === null || typeof roles !== 'object' || Array.isArray(roles) || !Object.hasOwn(roles, role)) {
466
+ const has = roles !== null && typeof roles === 'object' && !Array.isArray(roles) ? Object.keys(roles) : [];
467
+ throw new Error(`${bot} has no temporary-session role called ${role} in ${file}${has.length === 0 ? ', and no temp_roles at all' : `. It has: ${has.join(', ')}`}. Nothing was changed.`);
403
468
  }
404
469
  }
405
- }, (was) => ({
406
- ...was,
407
- temp_roles: Object.fromEntries(Object.entries(was.temp_roles).map(([role, written]) => {
408
- const asked = caps.findLast(([one]) => one === role);
409
- return [role, asked === undefined ? written : capped(written, asked[1])];
410
- })),
411
- }), write);
412
- return { bot, home, caps };
470
+ const capped = (written, cap) => {
471
+ const { cap: was, ...rest } = Array.isArray(written) ? { options: written } : written;
472
+ return cap === undefined ? rest : { ...rest, cap };
473
+ };
474
+ editBot(bots, bot, `set the caps of ${caps.map(([role]) => role).join(', ')}`, (doc) => {
475
+ for (const [role, cap] of caps) {
476
+ const node = doc.getIn(['temp_roles', role], true);
477
+ if (isSeq(node)) {
478
+ doc.setIn(['temp_roles', role], doc.createNode(capped(node.toJSON(), cap)));
479
+ } else if (cap === undefined) {
480
+ doc.deleteIn(['temp_roles', role, 'cap']);
481
+ } else {
482
+ doc.setIn(['temp_roles', role, 'cap'], cap);
483
+ }
484
+ }
485
+ }, (was) => ({
486
+ ...was,
487
+ temp_roles: Object.fromEntries(Object.entries(was.temp_roles).map(([role, written]) => {
488
+ const asked = caps.findLast(([one]) => one === role);
489
+ return [role, asked === undefined ? written : capped(written, asked[1])];
490
+ })),
491
+ }), write);
492
+ return { bot, home, caps };
493
+ });
413
494
  }
414
495
 
415
496
  /**
@@ -417,33 +498,37 @@ export function setRoleCaps(bots, bot, caps, { write = true } = {}) {
417
498
  * the file changed. A paused bot or session is one `obk up` leaves closed.
418
499
  */
419
500
  export function markPaused(bots, bot, name, paused) {
420
- if (name === undefined) {
421
- const known = readBot(botDir(bots, bot), bot);
422
- if ((known.paused === true) === paused) return false;
423
- editBot(
424
- bots, bot, `${paused ? 'pause' : 'unpause'} ${bot}`,
425
- (doc) => (paused ? doc.set('paused', true) : doc.delete('paused')),
426
- ({ paused: none, ...was }) => (paused ? { ...was, paused: true } : was),
427
- );
428
- return true;
429
- }
501
+ return withBotTurn(existingBot(bots, bot), () => {
502
+ if (name === undefined) {
503
+ const known = readBot(botDir(bots, bot), bot);
504
+ if ((known.paused === true) === paused) return false;
505
+ editBot(
506
+ bots, bot, `${paused ? 'pause' : 'unpause'} ${bot}`,
507
+ (doc) => (paused ? doc.set('paused', true) : doc.delete('paused')),
508
+ ({ paused: none, ...was }) => (paused ? { ...was, paused: true } : was),
509
+ );
510
+ return true;
511
+ }
430
512
 
431
- const { known, index } = sessionAt(bots, bot, name);
432
- const was = known.sessions[index];
433
- if ((was.paused === true) === paused) return false;
434
- const { paused: none, ...rest } = was;
435
- editSession(bots, bot, index, paused ? { ...rest, paused: true } : rest, `${paused ? 'pause' : 'unpause'} ${bot}'s session ${name}`);
436
- return true;
513
+ const { known, index } = sessionAt(bots, bot, name);
514
+ const was = known.sessions[index];
515
+ if ((was.paused === true) === paused) return false;
516
+ const { paused: none, ...rest } = was;
517
+ editSession(bots, bot, index, paused ? { ...rest, paused: true } : rest, `${paused ? 'pause' : 'unpause'} ${bot}'s session ${name}`);
518
+ return true;
519
+ });
437
520
  }
438
521
 
439
522
  /** Take the session `name` off the bot `bot`'s list. */
440
523
  export function dropSession(bots, bot, name) {
441
- const { index } = sessionAt(bots, bot, name);
442
- editBot(
443
- bots, bot, `take the session ${name} off ${bot}`,
444
- (doc) => doc.deleteIn(['sessions', index]),
445
- (was) => ({ ...was, sessions: was.sessions.filter((one, at) => at !== index) }),
446
- );
524
+ withBotTurn(existingBot(bots, bot), () => {
525
+ const { index } = sessionAt(bots, bot, name);
526
+ editBot(
527
+ bots, bot, `take the session ${name} off ${bot}`,
528
+ (doc) => doc.deleteIn(['sessions', index]),
529
+ (was) => ({ ...was, sessions: was.sessions.filter((one, at) => at !== index) }),
530
+ );
531
+ });
447
532
  }
448
533
 
449
534
  /** Where a session sits in its bot's list, refusing a bot or a session that is not there. */
@@ -484,7 +569,11 @@ function editSession(bots, bot, index, session, what) {
484
569
  * `write` is not false.
485
570
  */
486
571
  function editBot(bots, bot, what, edit, expected, write = true) {
487
- const file = path.join(existingBot(bots, bot), BOT_YAML);
572
+ const home = existingBot(bots, bot);
573
+ const file = path.join(home, BOT_YAML);
574
+ // Before the dry run too, so a command that writes elsewhere first is
575
+ // refused before it writes anything.
576
+ refuseOutside(home, file);
488
577
  const source = readFileSync(file, 'utf8');
489
578
  const doc = parseDocument(source);
490
579
  edit(doc);
@@ -497,7 +586,45 @@ function editBot(bots, bot, what, edit, expected, write = true) {
497
586
  throw refusal;
498
587
  }
499
588
  if (!changesExactly(source, text, expected)) throw refusal;
500
- if (write && text !== source) writeFileSync(file, text);
589
+ if (write && text !== source) writeWhole(file, text);
590
+ }
591
+
592
+ /**
593
+ * Write `text` over the bot.yaml `file` in one step, as the book is written:
594
+ * to a file of this process's own beside it, then moved over it. Two commands
595
+ * that write it at once leave one whole text or the other, never the one with
596
+ * the end of the other after it, which can read as a session neither command
597
+ * wrote (#585). A link to it is written through, to the file it names, and the
598
+ * file keeps its mode. A file the user made read-only is refused, as a write in
599
+ * place would be, though a move over it would go through. Whatever is already
600
+ * at the new file's name is taken away and the file is made new, never written
601
+ * through: a link a session left there would take the text out of the bot
602
+ * folder, and the move would make bot.yaml that link.
603
+ */
604
+ export function writeWhole(file, text) {
605
+ const real = realpathSync(file);
606
+ accessSync(real, constants.W_OK);
607
+ const half = `${real}.${process.pid}.part`;
608
+ rmSync(half, { force: true });
609
+ writeFileSync(half, text, { flag: 'wx', mode: statSync(real).mode & 0o777 });
610
+ renameSync(half, real);
611
+ }
612
+
613
+ /**
614
+ * Run `change`, which reads the bot's bot.yaml and writes it, while this
615
+ * command holds the bot's turn (#595): the read and the write are then of the
616
+ * same file, and another command's change cannot fall between them and be
617
+ * written over. Every kit change of a bot.yaml that is there is made in here;
618
+ * `bot create` writes its first whole, with no read before. Not taken twice:
619
+ * the commands that take it call only what does not.
620
+ */
621
+ export function withBotTurn(home, change) {
622
+ const turn = takeBotTurn(home);
623
+ try {
624
+ return change();
625
+ } finally {
626
+ turn.release();
627
+ }
501
628
  }
502
629
 
503
630
  /** A bot that is there, by its home, or the refusal a caller who named another is owed. */
@@ -537,9 +664,10 @@ export function changesExactly(source, text, expected) {
537
664
  }
538
665
 
539
666
  /** The bot.yaml of a bot nobody has edited yet. */
540
- function botYaml(name, harness, charter) {
667
+ function botYaml(name, harness, charter, allow) {
541
668
  const header = `# ${displayName(name)}. Ask Bot Father for changes rather than editing this file.\n\n`;
542
- return header + stringify({ name, harness, charter, rules: [], skills: [], sessions: [] }, YAML_OUT);
669
+ const bot = { name, harness, charter, rules: [], skills: [], sessions: [] };
670
+ return header + stringify(allow.length === 0 ? bot : { ...bot, allow }, YAML_OUT);
543
671
  }
544
672
 
545
673
  /**
package/src/clear.js CHANGED
@@ -105,7 +105,7 @@ const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
105
105
  * (or null) and the one it holds now.
106
106
  */
107
107
  export async function clearSession(bots, { bot, session }) {
108
- const it = sessionToType(bots, bot, session);
108
+ const it = sessionToType(bots, bot, session, 'clear');
109
109
  const was = readBook(it.home).sessions[session]?.session ?? null;
110
110
  const { command, handle, typing } = await enter(it, 'clear');
111
111
 
@@ -141,7 +141,7 @@ export async function clearSession(bots, { bot, session }) {
141
141
  * command began, and not yet when COMPACT_MS passed without one.
142
142
  */
143
143
  export async function compactSession(bots, { bot, session }) {
144
- const it = sessionToType(bots, bot, session);
144
+ const it = sessionToType(bots, bot, session, 'compact');
145
145
  // What the record holds when /compact is typed is not this compact's: one
146
146
  // that ends while the kit waits for the session to be idle is the one
147
147
  // before it (review of PR #466). So only what is written after counts.
@@ -168,13 +168,17 @@ export async function compactSession(bots, { bot, session }) {
168
168
  /**
169
169
  * The session as something to type into, once everything that refuses before
170
170
  * Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
171
+ * A `verb`, clear or compact, is refused on Pi until a later slice of #547.
171
172
  */
172
- export function sessionToType(bots, bot, session) {
173
+ export function sessionToType(bots, bot, session, verb) {
173
174
  botsNamed(bots, bot);
174
175
  const home = realpathSync(botDir(bots, bot));
175
176
  const known = readBot(home, bot);
176
177
  const [settings] = sessionsOf(known, session);
177
178
  const name = `${bot} ${session}`;
179
+ if (verb !== undefined && harnessOf(settings, known.harness) === 'pi') {
180
+ throw new Error(`${name} runs on Pi, and obk session ${verb} does not work on a Pi session yet: a later slice of #547 adds it. Nothing was typed.`);
181
+ }
178
182
  const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot}`;
179
183
  if (known.paused === true) throw new Error(`${bot} is paused, so ${name} is not running and nothing was typed. Bring it back with ${unpause}.`);
180
184
  if (settings.paused === true) throw new Error(`${name} is paused, so it is not running and nothing was typed. Bring it back with ${unpause} --session ${session}.`);