@assuredloop/orca-bot-kit 0.27.0 → 0.29.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/cli.js CHANGED
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, refuseCodexOutside, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
16
16
  import { COMMANDS } from './commands.js';
17
17
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
18
18
  import { checkHealth, orcaSettingFindings } from './health.js';
@@ -21,7 +21,7 @@ import { nameSession } from './name.js';
21
21
  import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
22
22
  import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
23
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
24
- import { allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
+ import { allowIn, beyondDefaults, botDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
25
25
  import { pauseSessions, unpauseSessions } from './pause.js';
26
26
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
27
27
  import { restartSessions } from './restart.js';
@@ -34,6 +34,7 @@ import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
34
34
  import { addSource, fetchSources } from './sources.js';
35
35
  import { answerSession, answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks, trustSessionHooks } from './temp.js';
36
36
  import { readUsage } from './usage.js';
37
+ import { parseRun, runBounded } from './run.js';
37
38
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
38
39
 
39
40
  const USAGE = `obk — Orca Bot Kit.
@@ -43,18 +44,19 @@ Usage:
43
44
  Create your bots folder: a git repo holding your
44
45
  bots' configuration, with Bot Father in it, and
45
46
  open Bot Father in Orca.
46
- obk bot create --bots <path> --name <bot> --harness claude|codex
47
+ obk bot create --bots <path> --name <bot> --harness claude|codex|pi
47
48
  [--charter <text>]
48
49
  Write a new bot in your bots folder. Nothing is
49
50
  opened in Orca until you run obk up.
50
51
  obk session add --bots <path> --bot <bot> --name <session>
51
- [--harness claude|codex] [--model <m>] [--effort <e>]
52
+ [--harness claude|codex|pi] [--model <m>] [--effort <e>]
52
53
  [--context <c>]
53
54
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
54
55
  [--extra-arg=<arg>]
55
56
  Add a session to a bot. Anything left out is the
56
57
  harness's own default; approval is auto, and only
57
- obk permission approval changes it. A long
58
+ obk permission approval changes it. A session on
59
+ Pi is dangerously-skip, the one level Pi has. A long
58
60
  start prompt lives in a file in the bot home, and
59
61
  --prompt-file names it.
60
62
  A value of your own that starts with a dash is
@@ -87,15 +89,18 @@ Usage:
87
89
  the bot's temporary sessions may be made at. A
88
90
  running session takes it when it next starts.
89
91
  obk session change --bots <path> --bot <bot> --session <session>
92
+ [--harness claude|codex|pi]
90
93
  [--model <m>] [--effort <e>] [--context <c>]
91
94
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
92
95
  [--extra-arg=<arg>]
93
96
  Change a session's settings. What you leave out
94
97
  stays as it is; a setting given empty, --model=,
95
98
  goes back to the harness's own default. A running
96
- session takes the change when it next starts. A
97
- session keeps its harness: to move it, retire it
98
- and add another.
99
+ session takes the change when it next starts. On
100
+ another harness it starts a new conversation, the
101
+ book keeps the old one in its history, and model,
102
+ effort and context go back to the new harness's
103
+ defaults unless given too.
99
104
  obk pause --bots <path> --bot <bot> [--session <name>]
100
105
  Stop a bot, or one of its sessions, for now: close
101
106
  its tabs and have obk up leave it closed. The book
@@ -219,6 +224,8 @@ Usage:
219
224
  folder and named in the message. A pair the
220
225
  harness's own messaging reaches is not carried:
221
226
  the address to write to is answered instead.
227
+ In a session's own tab it sends as that session,
228
+ and --from may name only that one.
222
229
  obk message check --bots <path> [--bot <bot>] [--session <name>] [--peek]
223
230
  Read what is waiting for a session and mark it
224
231
  read. --peek leaves it unread. Run in a session's
@@ -274,7 +281,8 @@ Usage:
274
281
  only while its tab is up in Orca. --run-on codex
275
282
  makes each run a temporary Codex session of the
276
283
  grooming session's, launched with --model, --effort
277
- and --extra-arg, which reports back by the kit's
284
+ and --extra-arg, which carries the grooming
285
+ session's own prompt, reports back by the kit's
278
286
  mail and is retired.
279
287
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
280
288
  Say what your sessions have used: the conversations
@@ -319,14 +327,28 @@ Usage:
319
327
  For the kit's own launch line, not for typing: run
320
328
  in the session's own tab, it gives the session its
321
329
  mailbox, or binds the one it has, to that tab.
330
+ obk run --timeout <seconds> -- <command> [<arg> ...]
331
+ Run one command with a hard time limit, with no
332
+ shell, and exit with its exit code. When the time
333
+ runs out, it stops the command and everything it
334
+ started (SIGTERM, then SIGKILL after 3 s), says so
335
+ on stderr, and exits 124. A command it cannot find
336
+ exits 127. A rule can allow it for one command,
337
+ such as Bash(obk run --timeout 300 -- go test:*).
338
+ It needs no --bots, and takes no --json.
322
339
  obk --version Print the kit's version.
323
340
  obk --help Print this text.
324
341
 
325
342
  Every command is safe to run again. Most only add what is missing; the ones
326
343
  that close a tab or take something away say so above. Every bot is allowed
327
- the kit's commands by default, except obk permission, retire, pause and init,
328
- which keep the user's yes.
344
+ the kit's commands by default, except obk permission, retire, pause, init and
345
+ run, which keep the user's yes.
329
346
  Add --json to any of them for the same answer as JSON.
347
+
348
+ For another build of Orca, OBK_ORCA names its CLI, and OBK_ORCA_DATA the full
349
+ path of the folder it keeps its data in, such as
350
+ $HOME/Library/Application Support/orca-lean.
351
+ The kit reads Orca's launch arguments from the profiles folder there.
330
352
  `;
331
353
 
332
354
 
@@ -374,6 +396,10 @@ function version() {
374
396
  }
375
397
 
376
398
  async function run(argv) {
399
+ // What follows `obk run --timeout <n> --` is the command's own, flags and
400
+ // all, so it is read by `run` alone, before the kit reads its flags (#532).
401
+ if (argv[0] === 'run') return runBounded(parseRun(argv.slice(1)));
402
+
377
403
  const { values, positionals, tokens } = parseArgs({
378
404
  args: argv,
379
405
  allowPositionals: true,
@@ -435,7 +461,7 @@ async function run(argv) {
435
461
  }
436
462
 
437
463
  // A permission rule narrows a command to one bots folder by its first words,
438
- // whatever comes after them (ADR 0036), and the last of two `--bots` would
464
+ // whatever comes after them (ADR 0050), and the last of two `--bots` would
439
465
  // win: so a second one is refused before anything runs.
440
466
  if (tokens.filter((token) => token.kind === 'option' && token.name === 'bots').length > 1) {
441
467
  throw new Error('--bots is given more than once. Give it once: one command works on one bots folder. Nothing was done.');
@@ -595,6 +621,8 @@ async function record(bots, bot) {
595
621
 
596
622
  const commands = {
597
623
  async init(bots, values) {
624
+ // Bot Father on Pi is not in the first slice of #547.
625
+ if (values.harness === 'pi') throw new Error('init does not take --harness pi yet: Bot Father runs on claude or codex, and its other bots may run on Pi. Nothing was written.');
598
626
  // Asked before anything is written, so an Orca that is down leaves the disk
599
627
  // exactly as it was and the caller can simply run the command again.
600
628
  refuseWhenOrcaIsDown();
@@ -827,7 +855,9 @@ const commands = {
827
855
  },
828
856
 
829
857
  'bot create'(bots, values) {
830
- const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
858
+ // The kit's default rules (ADR 0050) are in the bot's first bot.yaml.
859
+ const allow = botDefaults(bots, { harness: values.harness, sessions: [] });
860
+ const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter, allow });
831
861
  // The bot's AGENTS.md is the rules build's, here as everywhere else, so
832
862
  // that a new bot's file and a rebuilt one are written by the same code.
833
863
  const rules = [buildAgents(bots, made.home, readBot(made.home))];
@@ -838,9 +868,10 @@ const commands = {
838
868
  // instructions, so `up` will not start it, and saying "give it a session"
839
869
  // would send the caller past the thing that needs settling first.
840
870
  const trouble = rules[0].trouble !== undefined;
841
- // The kit's default rules (ADR 0036), for a bot that is set up to run: one
842
- // with no instructions gets them at the rules build that gives it some.
843
- const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home));
871
+ // The default rules go into the harness's files for a bot that is set up
872
+ // to run: one with no instructions gets them at the rules build that gives
873
+ // it some. They were added by this command, so its report names them.
874
+ const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home)).map((entry) => ({ ...entry, defaults: allow }));
844
875
  const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
845
876
  return {
846
877
  answer,
@@ -862,7 +893,7 @@ const commands = {
862
893
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
863
894
  }
864
895
  // Permission changes have commands of their own, which keep the user's yes
865
- // (ADR 0041): the old spelling refuses, and changes nothing.
896
+ // (ADR 0051): the old spelling refuses, and changes nothing.
866
897
  const moved = [['allow', 'permission allow'], ['disallow', 'permission disallow']].filter(([flag]) => values[flag] !== undefined);
867
898
  if (moved.length > 0) {
868
899
  throw new Error(`bot change no longer takes ${moved.map(([flag]) => `--${flag}`).join(' or ')}: permission rules change through ${moved.map(([, now]) => `${shellWord(ownCli())} ${now} --bots ${shellWord(bots)} --bot ${values.bot} --rule <rule>`).join(' and ')}, after the user's yes. Nothing was changed.`);
@@ -1069,7 +1100,7 @@ const commands = {
1069
1100
  ...(values.effort === undefined ? {} : { effort: values.effort }),
1070
1101
  ...(values['extra-arg'] === undefined ? {} : { extra_args: values['extra-arg'] }),
1071
1102
  };
1072
- // A run's launch may not widen its approval (ADR 0041).
1103
+ // A run's launch may not widen its approval (ADR 0051).
1073
1104
  if (values['run-on'] === 'codex') refuseApprovalArgs('codex', values['extra-arg']);
1074
1105
  const groom = grooming(bots, { at: values.at, ask, run });
1075
1106
  return { answer: { bots, groom }, lines: groomLines(groom, bots) };
@@ -1132,7 +1163,8 @@ const commands = {
1132
1163
  },
1133
1164
 
1134
1165
  async 'message send'(bots, values) {
1135
- const tab = callerTab(bots, values.from === undefined);
1166
+ // With --from too: in one of the fleet's tabs, the tab says who sends (#570).
1167
+ const tab = callerTab(bots, true);
1136
1168
  refuseWhenOrcaIsDown();
1137
1169
  const answer = await sendMessage(bots, {
1138
1170
  to: values.to,
@@ -1204,6 +1236,7 @@ const commands = {
1204
1236
 
1205
1237
  'session add'(bots, values) {
1206
1238
  refuseApproval(bots, values, 'session add');
1239
+ refuseOutsideWorkDir(bots, values);
1207
1240
  const added = addSession(bots, values.bot, settingsOf(values));
1208
1241
  const found = workDirFound(values, added.bot, added.home, added.session.name);
1209
1242
  const answer = { bots, bot: added.bot, home: added.home, session: added.session, found };
@@ -1233,21 +1266,23 @@ const commands = {
1233
1266
  },
1234
1267
 
1235
1268
  'session change'(bots, values) {
1236
- if (values.harness !== undefined) {
1237
- throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
1238
- }
1239
1269
  refuseApproval(bots, values, 'session change');
1240
1270
  const { name, ...settings } = settingsOf(values);
1241
1271
  const changed = changeSession(bots, values.bot, values.session, settings);
1242
1272
  const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
1243
1273
  const found = workDirFound(values, changed.bot, changed.home, values.session);
1274
+ const { moved } = changed;
1244
1275
  return {
1245
- answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, restart, found },
1276
+ answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, ...(moved === undefined ? {} : { moved }), restart, found },
1246
1277
  lines: [
1247
1278
  `changed session ${values.session} in ${path.join('bots', changed.bot, 'bot.yaml')}`,
1248
1279
  ...Object.entries(changed.session)
1249
1280
  .filter(([key]) => key !== 'name')
1250
1281
  .map(([key, value]) => ` ${key} ${oneLine(value)}`),
1282
+ ...(moved === undefined ? [] : [
1283
+ `moved from ${moved.from} to ${moved.to}: when it next starts, it starts a new conversation on ${moved.to}, and the book keeps its old one in the session's history.`,
1284
+ ...(moved.defaults.length === 0 ? [] : [` ${moved.defaults.join(', ')} went back to ${moved.to}'s own default${moved.defaults.length === 1 ? '' : 's'}.`]),
1285
+ ]),
1251
1286
  ...foundLines(found),
1252
1287
  `A running session takes this when it next starts: ${restart}`,
1253
1288
  ],
@@ -1290,7 +1325,7 @@ function toLines(answer, bots, where) {
1290
1325
  // says what to do instead, before the bot needs it (#521).
1291
1326
  return [
1292
1327
  ` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`,
1293
- " If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule, which they can add through the kit after the user's yes.",
1328
+ " If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule. Send the refusal to whoever adds your rules: your maker, your user or your fleet's manager.",
1294
1329
  ];
1295
1330
  }
1296
1331
  return [
@@ -1306,25 +1341,45 @@ function toLines(answer, bots, where) {
1306
1341
  * came to when one was asked for (#555).
1307
1342
  */
1308
1343
  function sentLines(answer, where) {
1309
- const road = " it is in its Orca mailbox, and Orca's own notice tells it to look once it is idle.";
1344
+ const road = noticeLines(answer);
1310
1345
  if (answer.interrupted === true) {
1311
1346
  return [
1312
- `${road.replace('once it is idle', 'now')}`,
1347
+ ...(road.length === 1 ? [road[0].replace('once it is idle', 'now')] : road),
1313
1348
  ` it was busy, so the kit pressed Escape once in its tab to end its turn.${answer.to.harness === 'codex' ? ' Codex keeps a command it was running as a background terminal after an Escape.' : ''}`,
1314
1349
  ];
1315
1350
  }
1316
1351
  if (answer.interrupted === false) {
1317
- return [road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
1352
+ return [...road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
1353
+ }
1354
+ return road;
1355
+ }
1356
+
1357
+ /**
1358
+ * Whether Orca's notice will tell the receiver of its letter. It will not while
1359
+ * Orca lists the receiver's tab as orphaned, as after a window restart: the
1360
+ * window has not loaded the tab (#579). A switch to the tab loads it, and is
1361
+ * named as a fact with its cost, not run and not urged: it moves the user's view.
1362
+ */
1363
+ function noticeLines(answer) {
1364
+ if (answer.orphaned === true) {
1365
+ return [
1366
+ " it is in its Orca mailbox, and it waits there: Orca lists its tab as orphaned, because the Orca window has not loaded that tab, and Orca will not tell it of the letter until the window loads it.",
1367
+ ` ${shellWord(orcaCli())} terminal switch --terminal ${shellWord(answer.handle)} loads the tab. It also moves the user's view to that tab, so what the user types can land in it.`,
1368
+ ];
1369
+ }
1370
+ if (answer.loadTrouble !== undefined) {
1371
+ return [` it is in its Orca mailbox. The kit could not tell whether the Orca window has loaded its tab (${answer.loadTrouble}), and Orca tells it of the letter only once the window has loaded the tab.`];
1318
1372
  }
1319
- return [road];
1373
+ return [" it is in its Orca mailbox, and Orca's own notice tells it to look once it is idle."];
1320
1374
  }
1321
1375
 
1322
1376
  /**
1323
1377
  * What there is to say about the work dir this command was given: one finding
1324
1378
  * when it leads out of the bot home, through `..` or a link or as a path
1325
1379
  * elsewhere, and nothing otherwise (#222). A bot's work and its clones belong
1326
- * under its own `work/`. It is said, not refused: the user may ask for a folder
1327
- * anywhere (PRD 6.3), and it is written as they gave it.
1380
+ * under its own `work/`. For a Claude or Pi session it is said, not refused: a
1381
+ * work dir is an instruction there (PRD 6.4), and it is written as given. A
1382
+ * Codex session's is refused before this (refuseCodexOutside).
1328
1383
  */
1329
1384
  function workDirFound(values, bot, home, session) {
1330
1385
  const workDir = workDirOf({ work_dir: values['work-dir'] }, home);
@@ -1336,16 +1391,35 @@ function workDirFound(values, bot, home, session) {
1336
1391
  }];
1337
1392
  }
1338
1393
 
1394
+ /**
1395
+ * Refuse a new Codex session a work dir outside its bot home (ADR 0045). A
1396
+ * changed session is judged in changeSession, on what it writes (#585).
1397
+ */
1398
+ function refuseOutsideWorkDir(bots, values) {
1399
+ if (values['work-dir'] === undefined) return;
1400
+ let bot;
1401
+ try {
1402
+ bot = readBot(botDir(bots, values.bot), values.bot);
1403
+ } catch {
1404
+ // A bot that is not there is the command's own to refuse.
1405
+ return;
1406
+ }
1407
+ const session = { name: values.name, harness: values.harness, work_dir: values['work-dir'] };
1408
+ refuseCodexOutside(values.bot, session, undefined, bot.harness, botDir(bots, values.bot), 'session add');
1409
+ }
1410
+
1339
1411
  /**
1340
1412
  * Refuse what would change a session's approval through `session add` or
1341
- * `session change` (ADR 0041): `--approval`, and an extra argument that sets
1413
+ * `session change` (ADR 0051): `--approval`, and an extra argument that sets
1342
1414
  * it on the session's harness. Only `permission approval` changes it.
1343
1415
  */
1344
1416
  function refuseApproval(bots, values, command) {
1345
1417
  if (values.approval !== undefined) {
1346
1418
  throw new Error(`${command} no longer takes --approval: a session's approval changes through ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${values.bot} --session ${values.session ?? values.name} --approval <level>, after the user's yes. Nothing was changed.`);
1347
1419
  }
1348
- if (values['extra-arg'] === undefined) return;
1420
+ // A move to another harness keeps the extra arguments the session has, and
1421
+ // one of them can set approval on the new harness (#565).
1422
+ if (values['extra-arg'] === undefined && values.harness === undefined) return;
1349
1423
  let bot;
1350
1424
  try {
1351
1425
  bot = readBot(botDir(bots, values.bot), values.bot);
@@ -1354,7 +1428,7 @@ function refuseApproval(bots, values, command) {
1354
1428
  return;
1355
1429
  }
1356
1430
  const session = bot.sessions.find((one) => one.name === (values.session ?? values.name)) ?? {};
1357
- refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg']);
1431
+ refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg'] ?? (command === 'session change' ? session.extra_args : undefined));
1358
1432
  }
1359
1433
 
1360
1434
  /**
@@ -1437,6 +1511,12 @@ function refuseWhenOrcaIsDown() {
1437
1511
  /** The one line that says which conversation this tab was given, and from where. */
1438
1512
  const howLines = (tab) => {
1439
1513
  if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
1514
+ if (tab.harnessChanged !== undefined) {
1515
+ return [
1516
+ ` it was told to start a new session: the book's conversation ${tab.harnessChanged} ran on ${tab.ranOn},`,
1517
+ " and the session has moved from it, so it went into the session's history.",
1518
+ ];
1519
+ }
1440
1520
  if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
1441
1521
  return [
1442
1522
  ` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
@@ -1658,7 +1738,7 @@ function rosterLines(roster, bots) {
1658
1738
  }
1659
1739
 
1660
1740
  lines.push(roster.length === 0
1661
- ? `No bots yet. Make one: ${shellWord(ownCli())} bot create --bots ${shellWord(bots)} --name <name> --harness claude|codex`
1741
+ ? `No bots yet. Make one: ${shellWord(ownCli())} bot create --bots ${shellWord(bots)} --name <name> --harness claude|codex|pi`
1662
1742
  : `${roster.length} bot${roster.length === 1 ? '' : 's'}. Your bots folder: ${bots}`);
1663
1743
  return lines;
1664
1744
  }
@@ -1725,9 +1805,9 @@ function charterRulesLines(bots, bot, rules) {
1725
1805
 
1726
1806
  /**
1727
1807
  * What became of each bot's permission rules: each of the kit's default rules
1728
- * the run added to the bot, word for word (ADR 0036), each rule of a command no
1808
+ * the run added to the bot, word for word (ADR 0050), each rule of a command no
1729
1809
  * longer in the set that the bot still holds, with the command that takes it
1730
- * back (ADR 0041), and the file the allowed ones were written into. Nothing for
1810
+ * back (ADR 0051), and the file the allowed ones were written into. Nothing for
1731
1811
  * a bot with nothing added, held or written.
1732
1812
  */
1733
1813
  function permissionsLines(permissions, bots) {
package/src/commands.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The kit's commands, each with the flags it cannot do without, which `obk`
2
2
  // reads to parse a command line; and the two lists every command is on one of:
3
- // those every bot is allowed by default (#527, ADR 0036), and those kept back
4
- // for the user's yes (ADR 0041). A new command is on neither until someone
3
+ // those every bot is allowed by default (#527, ADR 0050), and those kept back
4
+ // for the user's yes (ADR 0051). A new command is on neither until someone
5
5
  // puts it on one, and a test holds every command to that choice.
6
6
 
7
7
  /** The commands, and the flags each one cannot do without. */
@@ -46,18 +46,19 @@ export const COMMANDS = {
46
46
  'temp retire': ['bots', 'name'],
47
47
  'temp trust-hooks': ['bots', 'name'],
48
48
  'temp answer': ['bots', 'name'],
49
+ run: ['timeout'],
49
50
  };
50
51
 
51
52
  /**
52
53
  * The commands every bot is allowed by default, each narrowed to its bots
53
- * folder (ADR 0036). Only a command named here is in the default set; one that
54
+ * folder (ADR 0050). Only a command named here is in the default set; one that
54
55
  * runs whatever its arguments say never is.
55
56
  */
56
57
  export const DEFAULT_COMMANDS = [
57
58
  'up', 'unpause', 'health', 'groom', 'roster', 'usage',
58
- 'bot change', 'rules build',
59
+ 'rules build',
59
60
  'skills add', 'skills remove', 'skills build', 'skills fetch', 'skills update', 'source add',
60
- 'session add', 'session change', 'session clear', 'session compact',
61
+ 'session clear', 'session compact',
61
62
  'message to', 'message send', 'message check',
62
63
  'session record', 'session sent', 'session name', 'session mailbox',
63
64
  'session trust-hooks', 'session answer',
@@ -66,17 +67,28 @@ export const DEFAULT_COMMANDS = [
66
67
 
67
68
  /**
68
69
  * The commands kept out of the default permission set, which keep the user's
69
- * yes (ADR 0041): the ones that change permissions, close a long-lived
70
- * session, make the bots folder, or make a bot, which the owner leaves to Bot
71
- * Father (#548).
70
+ * yes (ADR 0051): the ones that change permissions, close a long-lived
71
+ * session, make the bots folder, or make or change a bot or its sessions,
72
+ * which the owner leaves to Bot Father (#548, #615); and `run`, which runs
73
+ * whatever its arguments say, so a rule for it names its command (#532).
72
74
  */
73
75
  export const KEPT_BACK = [
74
76
  'init', 'retire', 'pause', 'restart', 'bot create',
77
+ 'bot change', 'session add', 'session change',
75
78
  'permission allow', 'permission disallow', 'permission approval',
79
+ 'run',
76
80
  ];
77
81
 
78
82
  /**
79
- * The commands kit 0.26.0 gave every bot by default and that are kept back now
80
- * (#548). A bot may still hold their rules, which the kit names and leaves.
83
+ * The commands the kit gave every bot by default and that are kept back now
84
+ * (#548, #615). A bot may still hold their rules, which the kit names and
85
+ * leaves.
81
86
  */
82
- export const NO_LONGER_DEFAULT = ['restart', 'bot create'];
87
+ export const NO_LONGER_DEFAULT = ['restart', 'bot create', 'bot change', 'session add', 'session change'];
88
+
89
+ /**
90
+ * The commands kit 0.26.0 gave every bot by default and that the kit no longer
91
+ * has (#555). A bot may still hold their rules, which the kit names and leaves
92
+ * (#564).
93
+ */
94
+ export const REMOVED = ['session mail', 'session nudge'];
@@ -6,11 +6,11 @@
6
6
  // the kit never recorded must not be mistaken for a session that never had one,
7
7
  // so when the book cannot say, the harness is asked instead.
8
8
  //
9
- // Both harnesses keep their own record of every conversation, per working
9
+ // Every harness keeps its own record of every conversation, per working
10
10
  // directory, and every session starts at its bot home. Read only, never
11
11
  // written: these are the harness's files (tech notes, sections 2 and 3).
12
12
 
13
- import { existsSync, readdirSync, statSync } from 'node:fs';
13
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
 
@@ -22,6 +22,38 @@ const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(hom
22
22
  /** The folder's name is the working directory with everything else turned into a dash. */
23
23
  const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
24
24
 
25
+ /**
26
+ * Pi's sessions: one folder per working directory, named from its real path
27
+ * with the leading `/` taken off and `/`, `\` and `:` turned into dashes,
28
+ * between `--`; one `<time>_<id>.jsonl` per conversation (Pi 1.1.0,
29
+ * `getDefaultSessionDirPath`, tech notes, section 6).
30
+ */
31
+ const piDir = (home) => path.join(homedir(), '.pi', 'agent', 'sessions', `--${realHome(home).replace(/^[/\\]/, '').replaceAll(/[/\\:]/g, '-')}--`);
32
+
33
+ /** The folder as Pi sees it from inside: the real path, when there is one. */
34
+ function realHome(home) {
35
+ try {
36
+ return realpathSync(home);
37
+ } catch {
38
+ return home;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * A Pi session file's name, `<time>_<id>.jsonl`: the time is Pi's ISO time
44
+ * with `:` and `.` turned into dashes, and the id may hold a `_` of its own.
45
+ */
46
+ const PI_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_(.+)\.jsonl$/;
47
+
48
+ /** The id a Pi session file's name carries, or undefined for a file that is not one. */
49
+ const piIdOf = (name) => PI_FILE.exec(name)?.[1];
50
+
51
+ /** Where Pi keeps the conversation `id` of this bot home, or undefined when it has none yet. */
52
+ const piSession = (home, id) => {
53
+ const name = files(piDir(home)).find((one) => piIdOf(one) === id);
54
+ return name === undefined ? undefined : path.join(piDir(home), name);
55
+ };
56
+
25
57
  /** Codex's rollouts: a file per conversation, filed by the day it started. */
26
58
  const codexDir = () => path.join(homedir(), '.codex', 'sessions');
27
59
 
@@ -52,6 +84,7 @@ export function conversationsIn(harness, home, since) {
52
84
  */
53
85
  export function hasConversation(harness, home, id) {
54
86
  if (harness === 'claude') return existsSync(claudeTranscript(home, id));
87
+ if (harness === 'pi') return piSession(home, id) !== undefined;
55
88
  return codexRollout(id) !== undefined;
56
89
  }
57
90
 
@@ -120,7 +153,9 @@ export function claudeSubagentTranscripts(file) {
120
153
  */
121
154
  export function transcriptsIn(harness, home, since) {
122
155
  const from = since === undefined ? 0 : Date.parse(since);
123
- const found = harness === 'claude' ? claudeConversations(home) : codexConversations(home, from);
156
+ const found = harness === 'claude' ? claudeConversations(home)
157
+ : harness === 'pi' ? piConversations(home)
158
+ : codexConversations(home, from);
124
159
  return found
125
160
  .filter((one) => Number.isNaN(from) || one.at >= from)
126
161
  .sort((left, right) => left.at - right.at)
@@ -173,6 +208,14 @@ function userTexts(harness, line) {
173
208
  if (typeof content === 'string') return [content];
174
209
  return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
175
210
  }
211
+ if (harness === 'pi') {
212
+ // A turn is a `message` line, and the user's are role `user` (Pi 1.1.0,
213
+ // docs/session-format.md).
214
+ const message = parsed?.type === 'message' ? parsed.message : undefined;
215
+ if (message?.role !== 'user') return [];
216
+ if (typeof message.content === 'string') return [message.content];
217
+ return Array.isArray(message.content) ? message.content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
218
+ }
176
219
  const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
177
220
  if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
178
221
  return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
@@ -191,6 +234,33 @@ function claudeConversations(home) {
191
234
  });
192
235
  }
193
236
 
237
+ /**
238
+ * Pi's conversations in this bot home. The first line of each is its header,
239
+ * `{ type: 'session', id, timestamp, cwd }`, and says which conversation it is
240
+ * and when it began; the file's name says the id too.
241
+ */
242
+ function piConversations(home) {
243
+ return files(piDir(home))
244
+ .filter((name) => name.endsWith('.jsonl'))
245
+ .flatMap((name) => {
246
+ const file = path.join(piDir(home), name);
247
+ const header = piHeader(file);
248
+ const id = typeof header?.id === 'string' ? header.id : piIdOf(name);
249
+ const at = Date.parse(header?.timestamp ?? '') || startedAt(file);
250
+ return at === undefined || id === undefined || id === '' ? [] : [{ id, at, file }];
251
+ });
252
+ }
253
+
254
+ /** A Pi session's first line, when it is the header. */
255
+ function piHeader(file) {
256
+ try {
257
+ const parsed = JSON.parse(firstLine(file) ?? '');
258
+ return parsed?.type === 'session' ? parsed : undefined;
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ }
263
+
194
264
  /** When a transcript's first line says the conversation was, if it says at all. */
195
265
  function said(file) {
196
266
  try {
package/src/groom.js CHANGED
@@ -44,7 +44,7 @@ import path from 'node:path';
44
44
  import { botDir, readBot, requireBotsFolder } from './bot.js';
45
45
  import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
46
46
  import { claudeTranscript } from './conversations.js';
47
- import { harnessOf, ownCli, shellWord } from './launch.js';
47
+ import { harnessOf, ownCli, promptFile, shellWord, startPrompt } from './launch.js';
48
48
  import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
49
49
  import { BOT_FATHER, promptPath } from './up.js';
50
50
  import { lines } from './usage.js';
@@ -123,7 +123,7 @@ export function grooming(bots, { at, ask, run } = {}) {
123
123
  ? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
124
124
  : [];
125
125
 
126
- const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at, run });
126
+ const line = ask === undefined ? undefined : lineFor(ask, { bots, home, found, session, jobs, at, run });
127
127
  if (line !== undefined) typeInto(bots, home, session, tab, line);
128
128
 
129
129
  // A move is turning it on at another time, and is typed as one.
@@ -135,7 +135,7 @@ export function grooming(bots, { at, ask, run } = {}) {
135
135
  * nothing to ask. Everything that makes the ask impossible refuses here, before
136
136
  * Orca is asked for anything.
137
137
  */
138
- function lineFor(ask, { bots, session, jobs, at, run }) {
138
+ function lineFor(ask, { bots, home, found, session, jobs, at, run }) {
139
139
  // Nothing to turn off is an answer, unless a session is there to be told: an
140
140
  // `--on` typed a moment ago may still be waiting in its tab.
141
141
  if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
@@ -165,7 +165,8 @@ function lineFor(ask, { bots, session, jobs, at, run }) {
165
165
  // A move keeps what the runs are on, when every job there says the same.
166
166
  const runs = [...new Set(jobs.map((job) => JSON.stringify(job.run ?? null)))];
167
167
  const keep = ask === 'move' && runs.length === 1 ? JSON.parse(runs[0]) ?? undefined : undefined;
168
- return onLine(bots, cronOf(when), jobs, run ?? keep);
168
+ const onRun = run ?? keep;
169
+ return onLine(bots, cronOf(when), jobs, onRun === undefined ? undefined : { ...onRun, task: writeRunTask(bots, ownPrompt(home, found)) });
169
170
  }
170
171
 
171
172
  /**
@@ -367,7 +368,7 @@ function runElsewhere(bots, onRun) {
367
368
  ...(onRun.model === undefined ? [] : [`--model ${shellWord(onRun.model)}`]),
368
369
  ...(onRun.effort === undefined ? [] : [`--effort ${shellWord(onRun.effort)}`]),
369
370
  ...(onRun.extra_args ?? []).map((arg) => `--extra-arg=${shellWord(arg)}`),
370
- `--prompt-file ${shellWord(writeRunTask(bots))}`,
371
+ `--prompt-file ${shellWord(onRun.task)}`,
371
372
  ].join(' ');
372
373
  return `${markerOf(bots)}: the daily grooming for this fleet, run on ${onRun.harness} as a temporary session of yours `
373
374
  + `that does the work and reports back to you. First, if any of your temporary sessions whose names start with groom- `
@@ -390,13 +391,29 @@ function readReport(bots) {
390
391
  + `Then retire the run with ${retire}, its name in place of ${RUN_NAME}. Schedule nothing then: the job renewed itself when it fired.`;
391
392
  }
392
393
 
394
+ /**
395
+ * The grooming session's own prompt, as the user wrote it, or undefined when it
396
+ * has none: the standing duties a run on Codex carries too (#566). Without the
397
+ * work-dir note the session is launched with, since a run has a work dir of its
398
+ * own. Read when the line is typed, so a prompt changed since reaches the runs
399
+ * at the next `--on`.
400
+ */
401
+ function ownPrompt(home, session) {
402
+ try {
403
+ return startPrompt(session, { home });
404
+ } catch (error) {
405
+ throw new Error(`${BOT_FATHER}'s ${GROOMING} session's prompt file ${promptFile(session, home)} cannot be read (${error.code ?? error.message}), and each grooming run on Codex carries that prompt. Nothing was typed. Put the file back, or change the session's prompt, then run this again.`);
406
+ }
407
+ }
408
+
393
409
  /**
394
410
  * Write the task a Codex run is started with, and answer where: do the daily
395
- * grooming with the obk-grooming skill, send the report to the grooming session
396
- * by the kit's mail, and stop. It schedules nothing: the schedule is the
397
- * grooming session's.
411
+ * grooming with the obk-grooming skill, and the grooming session's own duties
412
+ * when its prompt has any, send the report to the grooming session by the
413
+ * kit's mail, and stop. It schedules nothing: the schedule is the grooming
414
+ * session's.
398
415
  */
399
- function writeRunTask(bots) {
416
+ function writeRunTask(bots, own) {
400
417
  const real = realHome(bots);
401
418
  const kit = shellWord(ownCli());
402
419
  const file = path.resolve(promptPath(real, BOT_FATHER, `${GROOMING}-run`));
@@ -405,6 +422,7 @@ function writeRunTask(bots) {
405
422
  + `since the last run's end up to it, counting with ${kit} usage --since <last end> --until <this end>, and write this end down for the next run. `
406
423
  + 'Work out what it cost with obk-finops, and keep each bot\'s profile notes. '
407
424
  + `The kit here is ${kit}: run it wherever a skill says obk. The bots folder is ${real}. `
425
+ + (own === undefined ? '' : `The ${GROOMING} session's own prompt is this run's too. It follows, word for word as the user wrote it:\n\n${own}\n\n`)
408
426
  + `When the grooming is done, send one short report to ${BOT_FATHER}/${GROOMING} by the kit's mail: `
409
427
  + `ask for the road with ${kit} message to --bots ${shellWord(real)} --to ${BOT_FATHER}/${GROOMING}, then send it with the message send command its answer names. `
410
428
  + 'Then stop: schedule nothing, make no session, and say nothing more.\n';