@assuredloop/orca-bot-kit 0.27.0 → 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/health.js CHANGED
@@ -25,7 +25,7 @@ import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
25
  import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
- import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord, workDirOf } from './launch.js';
28
+ import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
29
29
  import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
30
30
  import { permissionsTrouble } from './permissions.js';
31
31
  import { processesIn } from './processes.js';
@@ -140,8 +140,17 @@ function leftBeside(bots) {
140
140
  let known = true;
141
141
  for (const name of botNames(bots)) {
142
142
  try {
143
- for (const session of readBot(botDir(bots, name), name).sessions) {
143
+ const home = botDir(bots, name);
144
+ for (const session of readBot(home, name).sessions) {
144
145
  owned.add(promptPath(bots, name, session.name));
146
+ // The file a session names as its own prompt, which the kit reads to
147
+ // start it, and the file a link there leads to (#577).
148
+ if (set(session.prompt_file)) {
149
+ const file = promptFile(session, home);
150
+ owned.add(file);
151
+ const real = realpathOf(file);
152
+ if (real !== undefined) owned.add(real);
153
+ }
145
154
  // Where a live session's prompt was before #534: nothing reads it once
146
155
  // the session starts again, and its retire removes it.
147
156
  owned.add(oldPromptPath(bots, name, session.name));
@@ -153,7 +162,7 @@ function leftBeside(bots) {
153
162
 
154
163
  if (known) {
155
164
  for (const at of promptFilesIn(`${bots}.prompts`)) {
156
- if (owned.has(at)) continue;
165
+ if (owned.has(at) || owned.has(realpathOf(at))) continue;
157
166
  found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
158
167
  }
159
168
  }
@@ -255,7 +264,7 @@ function sessionSettings(home, bot) {
255
264
  /** What is wrong with the roles the bot's temporary sessions are made in (#465). */
256
265
  function rolesTrouble(home, bot) {
257
266
  const where = path.join(home, 'bot.yaml');
258
- // The widest approval they may be made at, the user's yes (ADR 0041).
267
+ // The widest approval they may be made at, the user's yes (ADR 0051).
259
268
  const level = bot.temp_approval;
260
269
  const approval = level === undefined || APPROVALS.includes(level) ? [] : [{
261
270
  where,
@@ -424,7 +433,7 @@ function inOrca(bots, home, bot, setups, sessions) {
424
433
 
425
434
  // What the harness has on record in this bot's folder, read once: it is a walk
426
435
  // through every conversation on the machine.
427
- const onRecord = recordIn(real ?? home, bot);
436
+ const onRecord = recordIn(real ?? home, bot, book);
428
437
  // A conversation the harness marks as a subagent's was never a session's, so a
429
438
  // note that holds one is not a loose end for it (#287). The note stays as it is.
430
439
  const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
@@ -493,10 +502,12 @@ function offTheBook(home, bot, book, onRecord) {
493
502
  return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
494
503
  }
495
504
 
496
- /** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
497
- function recordIn(home, bot) {
505
+ /** Every conversation on record in the bot's folder, from every harness any of its sessions runs on or a retired one may have, as `obk usage` reads them. */
506
+ function recordIn(home, bot, book) {
498
507
  const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
499
508
  if (harnesses.size === 0) harnesses.add(bot.harness);
509
+ // The book does not say which harness a retired session ran on, so any is (#573).
510
+ if (Array.isArray(book.retired) && book.retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
500
511
  return [...harnesses]
501
512
  .filter((harness) => HARNESSES.includes(harness))
502
513
  .flatMap((harness) => transcriptsIn(harness, home));
package/src/hooks.js CHANGED
@@ -17,7 +17,11 @@ import { isDeepStrictEqual } from 'node:util';
17
17
  import { leadsOutside } from './bot.js';
18
18
  import { ownCli, shellWord } from './launch.js';
19
19
 
20
- /** Where each harness reads a project's hooks, inside the bot home. */
20
+ /**
21
+ * Where each harness reads a project's hooks, inside the bot home. Pi has no
22
+ * hook of the kit's: the kit names a Pi session's id itself, on its launch
23
+ * line (#578).
24
+ */
21
25
  const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
22
26
 
23
27
  /** The hooks file `harness` reads in the bot at `home`. */
@@ -82,6 +86,7 @@ export const nameCommand = (bots, bot, cli = ownCli()) =>
82
86
  * the kit's own entry is written (PRD 6.3).
83
87
  */
84
88
  export function installHook(home, harness, { bots, bot }) {
89
+ if (HOOK_FILE[harness] === undefined) return;
85
90
  const file = path.join(home, HOOK_FILE[harness]);
86
91
  const away = outside(home, file);
87
92
  if (away !== undefined) throw new Error(away);
@@ -117,6 +122,7 @@ export function installHook(home, harness, { bots, bot }) {
117
122
  * nothing else says so — the book simply stops being true.
118
123
  */
119
124
  export function hookTrouble(home, harness, { bots, bot }) {
125
+ if (HOOK_FILE[harness] === undefined) return undefined;
120
126
  const file = path.join(home, HOOK_FILE[harness]);
121
127
  const stale = `Until it is there, nothing tells the book which conversation this bot's ${harness} sessions are running as, and the book goes stale.`;
122
128
 
package/src/init.js CHANGED
@@ -10,7 +10,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync,
10
10
  import path from 'node:path';
11
11
  import { parseDocument } from 'yaml';
12
12
 
13
- import { BOT_GITIGNORE, changesExactly, YAML_OUT } from './bot.js';
13
+ import { BOT_GITIGNORE, changesExactly, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
14
14
  import { RECORD, SKILL_DIRS } from './skills.js';
15
15
 
16
16
  /**
@@ -266,6 +266,11 @@ function checkBotFather(file, harness) {
266
266
  if (bot.sessions !== undefined && bot.sessions !== null && !Array.isArray(bot.sessions)) {
267
267
  throw new Error(`${file} has a sessions entry that is not a list, so init cannot add Bot Father's daily session to it. Fix it, then run init again.`);
268
268
  }
269
+ // One init would complete is refused here, before init writes anything, when a
270
+ // link takes it out of the bot folder (#569).
271
+ if (bot.harness === undefined || !Array.isArray(bot.sessions) || bot.sessions.length === 0) {
272
+ refuseOutside(path.dirname(file), file);
273
+ }
269
274
  }
270
275
 
271
276
  /**
@@ -281,35 +286,37 @@ function checkBotFather(file, harness) {
281
286
  * are theirs, and `up` brings up what it finds.
282
287
  */
283
288
  function completeBotFather(file, harness) {
284
- const source = readFileSync(file, 'utf8');
285
- const doc = parseDocument(source);
286
-
287
- // What is missing is settled from the document, not from the text it is
288
- // written back as: a file that needs nothing is not rewritten, however the
289
- // library would have laid it out.
290
- const sessions = doc.get('sessions', true);
291
- const needsSession = sessions?.items === undefined || sessions.items.length === 0;
292
- const needsHarness = doc.get('harness') === undefined;
293
- if (!needsSession && !needsHarness) return [];
294
-
295
- if (sessions?.items === undefined) {
296
- doc.set('sessions', [{ name: DAILY_SESSION }]);
297
- } else if (needsSession) {
298
- sessions.flow = false;
299
- doc.addIn(['sessions'], { name: DAILY_SESSION });
300
- }
301
- if (needsHarness) doc.set('harness', harness);
289
+ return withBotTurn(path.dirname(file), () => {
290
+ const source = readFileSync(file, 'utf8');
291
+ const doc = parseDocument(source);
292
+
293
+ // What is missing is settled from the document, not from the text it is
294
+ // written back as: a file that needs nothing is not rewritten, however the
295
+ // library would have laid it out.
296
+ const sessions = doc.get('sessions', true);
297
+ const needsSession = sessions?.items === undefined || sessions.items.length === 0;
298
+ const needsHarness = doc.get('harness') === undefined;
299
+ if (!needsSession && !needsHarness) return [];
300
+
301
+ if (sessions?.items === undefined) {
302
+ doc.set('sessions', [{ name: DAILY_SESSION }]);
303
+ } else if (needsSession) {
304
+ sessions.flow = false;
305
+ doc.addIn(['sessions'], { name: DAILY_SESSION });
306
+ }
307
+ if (needsHarness) doc.set('harness', harness);
302
308
 
303
- const text = doc.toString(YAML_OUT);
309
+ const text = doc.toString(YAML_OUT);
304
310
 
305
- if (!changesExactly(source, text, (was) => ({
306
- ...was,
307
- harness: was.harness ?? harness,
308
- sessions: was.sessions?.length > 0 ? was.sessions : [{ name: DAILY_SESSION }],
309
- }))) {
310
- throw new Error(`${file} cannot be completed without changing something else in it, so nothing was written. Give it a harness and a session by hand, then run init again.`);
311
- }
311
+ if (!changesExactly(source, text, (was) => ({
312
+ ...was,
313
+ harness: was.harness ?? harness,
314
+ sessions: was.sessions?.length > 0 ? was.sessions : [{ name: DAILY_SESSION }],
315
+ }))) {
316
+ throw new Error(`${file} cannot be completed without changing something else in it, so nothing was written. Give it a harness and a session by hand, then run init again.`);
317
+ }
312
318
 
313
- writeFileSync(file, text);
314
- return [BOT_FATHER_YAML];
319
+ writeWhole(file, text);
320
+ return [BOT_FATHER_YAML];
321
+ });
315
322
  }
package/src/launch.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // the harness up and carries the session's start prompt with it.
3
3
  //
4
4
  // The flag mapping is PRD 6.4 and ADR 0015, re-checked against the installed
5
- // CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
5
+ // CLIs (Claude Code 2.1.278, Codex 0.155.1, Pi 1.1.0). Every session is given its
6
6
  // approval level explicitly, so a user's own global harness defaults cannot
7
7
  // leak into a bot. The kit names no model: a session that sets none gets the
8
8
  // harness's own.
@@ -27,6 +27,11 @@ const APPROVAL = {
27
27
  ask: ['-a', 'on-request'],
28
28
  'dangerously-skip': ['--dangerously-bypass-approvals-and-sandbox'],
29
29
  },
30
+ // Pi asks before no tool call and has no rule for each command, so it has
31
+ // one level, and no flag for it (the owner, #547).
32
+ pi: {
33
+ 'dangerously-skip': [],
34
+ },
30
35
  };
31
36
 
32
37
  export const APPROVALS = Object.keys(APPROVAL.claude);
@@ -44,32 +49,179 @@ export const HARNESSES = Object.keys(APPROVAL);
44
49
  export const approvalRank = (level) => ['ask', 'auto', 'dangerously-skip'].indexOf(level);
45
50
 
46
51
  /**
47
- * The harness flags that set approval or permissions, which only
48
- * `obk permission approval` may change, not an extra argument (ADR 0041).
49
- * Codex's `-a` and `-s` take their value glued on too.
52
+ * The harness flags that set approval or permissions, or widen what a session
53
+ * may write or run, which only `obk permission approval` may change, not an
54
+ * extra argument (ADR 0051, ADR 0049). Codex's short flags take their value
55
+ * glued on too. Pi's `--approve` and `-a` make it trust the folder's own
56
+ * files, which the kit's `--no-approve` keeps it from (#578).
50
57
  */
51
58
  const APPROVAL_FLAGS = {
52
- claude: ['--permission-mode', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--allowedTools', '--allowed-tools'],
53
- codex: ['-a', '--ask-for-approval', '--approve-for-me', '--dangerously-bypass-approvals-and-sandbox', '-s', '--sandbox'],
59
+ claude: [
60
+ '--permission-mode', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--allowedTools', '--allowed-tools',
61
+ '--add-dir', '--settings', '--setting-sources', '--plugin-dir', '--plugin-url', '--mcp-config', '--agents', '--agent', '--debug-file', '--bare', '--restricted',
62
+ ],
63
+ codex: [
64
+ '-a', '--ask-for-approval', '--approve-for-me', '--dangerously-bypass-approvals-and-sandbox', '-s', '--sandbox',
65
+ '--add-dir', '-C', '--cd', '-p', '--profile', '--enable', '--disable', '--remote', '--remote-auth-token-env',
66
+ ],
67
+ pi: ['--approve', '-a'],
68
+ };
69
+
70
+ /**
71
+ * The Codex `-c` keys an extra argument may set, with any value or with only
72
+ * the values named. Every other key is refused: Codex 0.163.0's config has
73
+ * dozens that widen a session, and more in each release (ADR 0049).
74
+ */
75
+ const CONFIG_KEYS = {
76
+ model: true,
77
+ model_reasoning_effort: true,
78
+ model_reasoning_summary: true,
79
+ model_verbosity: true,
80
+ model_context_window: true,
81
+ model_auto_compact_token_limit: true,
82
+ plan_mode_reasoning_effort: true,
83
+ service_tier: true,
84
+ hide_agent_reasoning: true,
85
+ show_raw_agent_reasoning: true,
86
+ tool_output_token_limit: true,
87
+ 'tui.animations': true,
88
+ 'tui.show_tooltips': true,
89
+ 'tui.notifications': true,
90
+ 'tui.notification_method': true,
91
+ 'tui.notification_condition': true,
92
+ 'tui.alternate_screen': true,
93
+ 'tui.theme': true,
94
+ 'tui.vim_mode_default': true,
95
+ 'tui.status_line': true,
96
+ 'tui.terminal_title': true,
97
+ 'tui.raw_output_mode': true,
98
+ 'tui.disable_paste_burst': true,
99
+ 'tui.fullscreen_transcript': true,
100
+ 'tui.mouse_scroll_speed': true,
101
+ 'tui.show_server_version_notice': true,
102
+ // The network the kit turns on, which a user may turn off (NETWORK_ACCESS).
103
+ 'sandbox_workspace_write.network_access': ['true', 'false'],
104
+ // These only narrow: `false` is Codex's default.
105
+ 'sandbox_workspace_write.exclude_tmpdir_env_var': ['true', 'false'],
106
+ 'sandbox_workspace_write.exclude_slash_tmp': ['true', 'false'],
107
+ 'features.sleep_tool': ['false'],
108
+ // No update check at start, so no update offer (#581).
109
+ check_for_update_on_startup: ['false'],
54
110
  };
55
111
 
112
+ /**
113
+ * The one trust a `-c` may give: one folder, an absolute path in a TOML basic
114
+ * string, trusted. It is what the kit's own answer to Codex's trust screen
115
+ * gives, for that run only and with nothing written to config.toml (ADR 0049).
116
+ */
117
+ const TRUST = /^\{[ \t]*"([^"\\\x00-\x1f\x7f]*)"[ \t]*=[ \t]*\{[ \t]*trust_level[ \t]*=[ \t]*"trusted"[ \t]*\}[ \t]*\}$/;
118
+
119
+ /** Whether a Codex `-c` value, `key=value`, sets only what an extra argument may. */
120
+ function allowedConfig(setting) {
121
+ const at = setting.indexOf('=');
122
+ if (at === -1) return false;
123
+ const key = setting.slice(0, at).trim();
124
+ const value = setting.slice(at + 1).trim();
125
+ if (key === 'projects') {
126
+ const folder = TRUST.exec(value)?.[1];
127
+ return folder !== undefined && folder.startsWith('/') && !folder.split('/').includes('..');
128
+ }
129
+ if (!/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/.test(key) || !Object.hasOwn(CONFIG_KEYS, key)) return false;
130
+ return CONFIG_KEYS[key] === true || CONFIG_KEYS[key].includes(value);
131
+ }
132
+
56
133
  /**
57
134
  * Refuse extra arguments that set a session's approval or permissions on
58
- * `harness`, before anything is written, naming the command that does it.
135
+ * `harness`, or widen what it may write or run, before anything is written,
136
+ * naming the command that does it (ADR 0051, ADR 0049).
137
+ * `args` is a list, or, as a user may write `extra_args` by hand, one string
138
+ * of shell text. The kit types such a string into the tab's shell as it
139
+ * stands, so the guard takes it only when it can be sure of the words the
140
+ * shell makes of it, and refuses it otherwise (#565).
59
141
  */
60
142
  export function refuseApprovalArgs(harness, args) {
61
- const found = (args ?? []).find((arg) => (APPROVAL_FLAGS[harness] ?? []).some((flag) => arg === flag
62
- || arg.startsWith(`${flag}=`)
63
- || (/^-[as]$/.test(flag) && arg.startsWith(flag) && !arg.startsWith('--'))));
64
- if (found === undefined) return;
65
- throw new Error(`--extra-arg=${found} sets the session's approval or permissions on ${harness}, and only obk permission approval changes those, after the user's yes. Nothing was changed.`);
143
+ const words = Array.isArray(args) ? args.map(String) : set(args) ? sureWords(String(args)) : [];
144
+ if (words === undefined) {
145
+ throw new Error(`extra_args is written as one string, ${quoted(String(args))}, with shell syntax the kit cannot be sure of the words of, so it cannot tell whether it sets the session's approval or permissions. Write extra_args as a list, one --extra-arg for each argument. Nothing was changed.`);
146
+ }
147
+ for (let at = 0; at < words.length; at += 1) {
148
+ const arg = words[at];
149
+ if ((APPROVAL_FLAGS[harness] ?? []).some((flag) => arg === flag
150
+ || arg.startsWith(`${flag}=`)
151
+ || (/^-[asCp]$/.test(flag) && arg.startsWith(flag) && !arg.startsWith('--')))) {
152
+ throw new Error(`--extra-arg=${arg} sets the session's approval or permissions on ${harness}, or widens what it may write or run, and only obk permission approval changes those, after the user's yes. Nothing was changed.`);
153
+ }
154
+ // Codex's config override: the value is the next word, or after `=`, or glued on.
155
+ const setting = harness !== 'codex' ? undefined
156
+ : arg === '-c' || arg === '--config' ? (at += 1, words[at] ?? '')
157
+ : arg.startsWith('--config=') ? arg.slice('--config='.length)
158
+ : arg.startsWith('-c') && !arg.startsWith('--') ? arg.slice(2).replace(/^=/, '')
159
+ : undefined;
160
+ if (setting !== undefined && !allowedConfig(setting)) {
161
+ throw new Error(`--extra-arg=-c ${setting} sets a Codex setting that an extra argument may not set: the kit takes -c only for the model, the display, the network switch and a few settings that narrow (ADR 0049). Set the others in your own ~/.codex/config.toml; the session's approval and sandbox change only through obk permission approval, after the user's yes. Nothing was changed.`);
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The words of the shell text `text`, when the kit can be sure they are the
168
+ * ones the tab's shell makes of it, and undefined when it cannot (#565). Sure
169
+ * means plain words of the characters `quoted` leaves bare, separated by
170
+ * spaces, with no unquoted `=` before any other character of its word, empty
171
+ * quotes included (zsh's equals expansion makes `''=sh` into /bin/sh); single quotes with no
172
+ * backslash (fish reads `\'` and `\\` there) and no control character; and
173
+ * double quotes with none of `$`, backquote, backslash or `!`, and no control
174
+ * character. Read so, it is the same words in zsh, bash and fish. Everything
175
+ * else, a newline or a tab included, is shell syntax this does not read.
176
+ */
177
+ function sureWords(text) {
178
+ const words = [];
179
+ let word;
180
+ let quote;
181
+ for (const char of text) {
182
+ if (/[\x00-\x1f\x7f]/.test(char)) return undefined;
183
+ if (quote === "'") {
184
+ if (char === '\\') return undefined;
185
+ if (char === "'") quote = undefined;
186
+ else word += char;
187
+ } else if (quote === '"') {
188
+ if ('$`\\!'.includes(char)) return undefined;
189
+ if (char === '"') quote = undefined;
190
+ else word += char;
191
+ } else if (char === ' ') {
192
+ if (word !== undefined) words.push(word);
193
+ word = undefined;
194
+ } else if (char === "'" || char === '"') {
195
+ word ??= '';
196
+ quote = char;
197
+ } else if (/^[A-Za-z0-9,._+:@%/=-]$/.test(char) && !(char === '=' && !word)) {
198
+ word = (word ?? '') + char;
199
+ } else {
200
+ return undefined;
201
+ }
202
+ }
203
+ if (quote !== undefined) return undefined;
204
+ if (word !== undefined) words.push(word);
205
+ return words;
66
206
  }
67
207
 
68
208
  /** The level a session that names none runs at (ADR 0015). */
69
209
  export const DEFAULT_APPROVAL = 'auto';
70
210
 
211
+ /** The level a session on `harness` is written with when it names none: Pi has only one (#547). */
212
+ export const defaultApproval = (harness) => (harness === 'pi' ? 'dangerously-skip' : DEFAULT_APPROVAL);
213
+
214
+ /**
215
+ * Why a level is refused on Pi, or undefined when it is taken there. The
216
+ * owner's decision (#547): every Pi session counts as dangerously-skip.
217
+ */
218
+ export function piApprovalTrouble(level, who = 'this session') {
219
+ if (!set(level) || level === 'dangerously-skip') return undefined;
220
+ return `${who} runs on Pi and is set to approval ${level}. Pi has no approval prompt and no permission rules, so every Pi session runs as dangerously-skip (the owner's decision, #547); auto and ask are refused on Pi. Set it to dangerously-skip, or run the session on claude or codex.`;
221
+ }
222
+
71
223
  /** A setting the user left out. An empty string is one too: it means the harness's own. */
72
- const set = (value) => value !== undefined && value !== null && String(value).trim() !== '';
224
+ export const set = (value) => value !== undefined && value !== null && String(value).trim() !== '';
73
225
 
74
226
  /** Which harness a session runs on: its own, or the bot's. */
75
227
  export const harnessOf = (session, botHarness) => (set(session.harness) ? session.harness : botHarness);
@@ -201,6 +353,22 @@ export function sessionTrouble(session, harness, home) {
201
353
  if (set(session.approval) && !APPROVALS.includes(session.approval)) {
202
354
  return `${where(session)} is set to approval ${session.approval}, and the levels are ${APPROVALS.join(', ')}.`;
203
355
  }
356
+ if (harness === 'pi') {
357
+ const trouble = piApprovalTrouble(session.approval, where(session));
358
+ if (trouble !== undefined) return trouble;
359
+ // Pi has no context setting: a model's own name carries it, as in
360
+ // cursor/claude-opus-5-5@1m (tech notes, section 6).
361
+ if (set(session.context)) {
362
+ return `${where(session)} sets a context of ${session.context} on Pi, and Pi has no context setting: the model's name carries it, as in cursor/claude-opus-5-5@1m. Name such a model, or leave the context out.`;
363
+ }
364
+ // Pi 1.1.0 reads a word after `--` that starts with `@` as a file to
365
+ // attach, and has no escape for it, so such a prompt cannot arrive as
366
+ // written (tech notes, section 6). It is checked trimmed, as it is sent.
367
+ const said = set(session.prompt_file) ? readFileSync(promptFile(session, home), 'utf8') : session.prompt;
368
+ if (set(said) && String(said).trim().startsWith('@')) {
369
+ return `${where(session)} runs on Pi, and its prompt begins with @. Pi would read the prompt as a file to attach, not as text. Give it a prompt that does not begin with @.`;
370
+ }
371
+ }
204
372
  // Codex is given the context window as a number of tokens. `1m` is what a
205
373
  // Claude session would say, and copying it here does not fail at the kit but
206
374
  // in Codex, which exits with a type error the moment it starts.
@@ -231,16 +399,21 @@ const where = (session) => (set(session.name) ? `session ${session.name}` : 'thi
231
399
  * send would land on that list and answer it. A prompt given as an argument is
232
400
  * held by the harness until it is ready for it (tech notes, section 1).
233
401
  */
234
- export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit }) {
402
+ export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit, sessionId, name }) {
235
403
  const trouble = sessionTrouble(session, harness, home);
236
404
  if (trouble !== undefined) throw new Error(trouble);
405
+ // Pi is told which conversation it is, a new one too: the kit names it, and
406
+ // that is how the book knows it (tech notes, section 6).
407
+ if (harness === 'pi' && resume === undefined && sessionId === undefined) {
408
+ throw new Error(`${where(session)} runs on Pi, and a Pi session is started with the id the book gives it, so the kit names one before it launches. None was given.`);
409
+ }
237
410
 
238
411
  const words = [harness];
239
412
  // Codex resumes through a subcommand, which has to come first; Claude Code
240
413
  // resumes through a flag, which goes with the session id at the end. Both
241
414
  // take every other flag exactly as a fresh session does.
242
415
  if (resume !== undefined && harness === 'codex') words.push('resume');
243
- words.push(...APPROVAL[harness][set(session.approval) ? session.approval : DEFAULT_APPROVAL]);
416
+ words.push(...APPROVAL[harness][set(session.approval) ? session.approval : defaultApproval(harness)]);
244
417
  // Codex 0.157 shares one background server between sessions by default, and
245
418
  // a resume through it was seen to fail; without it Codex runs as 0.156 did
246
419
  // (tech notes, section 3, #330). Codex refuses the flag twice, so the user's
@@ -254,6 +427,16 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
254
427
  // The context window rides on the model name: `sonnet[1m]`.
255
428
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
256
429
  if (set(session.effort)) words.push('--effort', String(session.effort));
430
+ } else if (harness === 'pi') {
431
+ // Pi trusts none of the folder's own files (`.pi/`, `.agents/skills`), so it
432
+ // shows no trust screen and runs nothing the kit has not looked at (#578).
433
+ words.push('--no-approve');
434
+ // The id the book holds, resumed or new: Pi opens the conversation with it,
435
+ // and makes it when there is none (tech notes, section 6).
436
+ words.push('--session-id', resume ?? sessionId);
437
+ if (name !== undefined) words.push('--name', name);
438
+ if (set(session.model)) words.push('--model', session.model);
439
+ if (set(session.effort)) words.push('--thinking', String(session.effort));
257
440
  } else {
258
441
  // Without this the Orca CLI cannot reach Orca from inside the sandbox, so
259
442
  // the session is in no fleet at all. The user's own setting of it wins.
@@ -313,7 +496,7 @@ export function startPrompt(session, { home, workDir }) {
313
496
  }
314
497
 
315
498
  /** Where a session's prompt file is: in the bot home, where the user put it. */
316
- export const promptFile = (session, home) => path.resolve(home, session.prompt_file);
499
+ export const promptFile = (session, home) => path.resolve(home, String(session.prompt_file));
317
500
 
318
501
  /**
319
502
  * A prompt short and simple enough to type into the launch line as it is: one
@@ -336,10 +519,11 @@ function promptWords(prompt, fromFile) {
336
519
 
337
520
  /**
338
521
  * The session the harness is told to pick up again, when there is one: a flag
339
- * on Claude Code, the argument of the `resume` subcommand on Codex.
522
+ * on Claude Code, the argument of the `resume` subcommand on Codex. Pi's is
523
+ * its `--session-id`, which goes with its other flags.
340
524
  */
341
525
  const resumeWords = (harness, resume) => {
342
- if (resume === undefined) return [];
526
+ if (resume === undefined || harness === 'pi') return [];
343
527
  return harness === 'claude' ? ['--resume', quoted(resume)] : [quoted(resume)];
344
528
  };
345
529
 
package/src/message.js CHANGED
@@ -13,7 +13,8 @@
13
13
  // One road and one signal (#555, ADR 0042): the letter goes into the
14
14
  // receiver's mailbox, and Orca's own notice tells the receiver to look once it
15
15
  // is idle. The kit types nothing into the receiver's tab, except the one
16
- // Escape a sender asks for with --interrupt.
16
+ // Escape a sender asks for with --interrupt. Orca gives no notice to a tab its
17
+ // window has not loaded, so the send says when the receiver's is one (#579).
17
18
 
18
19
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
20
  import path from 'node:path';
@@ -126,7 +127,7 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
126
127
  * nothing, which is the whole of "the bot never picks the transport".
127
128
  */
128
129
  export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread, interrupt = false }) {
129
- const from = whoIsWriting(bots, sender, tab);
130
+ const from = whoIsSending(bots, sender, tab);
130
131
  const to = findSession(bots, target);
131
132
  const road = roadBetween(from, to);
132
133
 
@@ -159,6 +160,8 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
159
160
  }
160
161
 
161
162
  const written = bodyOf(bots, { from, to, subject, text, textFile });
163
+ // Looked at before the post, which is the send's last call to Orca (PRD 6.9).
164
+ const shown = loaded(to);
162
165
  // The Escape goes first, so the turn has ended when the letter is in.
163
166
  const interrupted = interrupt ? interruptBusy(to) : {};
164
167
  const message = postMessage({
@@ -176,9 +179,30 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
176
179
  thread: message.thread_id ?? thread,
177
180
  file: written.file,
178
181
  ...interrupted,
182
+ ...shown,
179
183
  };
180
184
  }
181
185
 
186
+ /**
187
+ * Whether Orca's window has loaded the receiver's tab, looked at just before
188
+ * the letter goes in. Orca gives its notice only to a pane the window has loaded,
189
+ * and lists one it has not as orphaned, as after a window restart (#579; tech
190
+ * notes, section 1). So an orphaned tab is said, with its handle, and a
191
+ * listing that cannot be read is said too, rather than taken for a loaded tab.
192
+ * The look is made once: a tab orphaned after the send is not seen here.
193
+ */
194
+ function loaded(to) {
195
+ if (to.tab === undefined) return {};
196
+ let listed;
197
+ try {
198
+ listed = tabs(to.home, { timeoutMs: LOOK_MS });
199
+ } catch (error) {
200
+ return { loadTrouble: error.message };
201
+ }
202
+ const tab = listed.find((one) => one.tabId === to.tab);
203
+ return tab?.orphaned === true ? { orphaned: true, handle: tab.handle } : {};
204
+ }
205
+
182
206
  /**
183
207
  * What is waiting for a session, and the reading of it.
184
208
  *
@@ -364,6 +388,22 @@ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which
364
388
  return found;
365
389
  }
366
390
 
391
+ /**
392
+ * The session that is sending. In one of this fleet's tabs it is the tab's own
393
+ * session, and `--from` may name only that one: otherwise a bot could send as
394
+ * another, and the reply and the receiver's check would take it for that one
395
+ * (#570). Outside the fleet's tabs `--from` says who is writing, as before.
396
+ */
397
+ function whoIsSending(bots, sender, tab) {
398
+ const caller = tab === undefined ? undefined : sessionInTab(bots, tab);
399
+ if (caller === undefined) return whoIsWriting(bots, sender, tab);
400
+ if (sender === undefined) return caller;
401
+
402
+ const named = findSession(bots, sender);
403
+ if (named.bot === caller.bot && named.session === caller.session) return caller;
404
+ throw new Error(`this command runs in ${caller.bot}/${caller.session}'s Orca tab, ${tab}, so it sends as ${caller.bot}/${caller.session} and not as ${named.bot}/${named.session}. Nothing was sent. Leave out --from, or name ${caller.bot}/${caller.session}.`);
405
+ }
406
+
367
407
  /**
368
408
  * The session the book says is in this Orca tab, wherever in the fleet it is,
369
409
  * when the caller is that session.
@@ -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 0034).
2
+ -- a project was removed (#343, ADR 0046).
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
@@ -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 0034): `project.update`, which makes Orca's
5
+ // Orca's CLI does not offer (ADR 0046): `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