agent-bios 0.15.0 → 0.17.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.
Files changed (66) hide show
  1. package/DEPENDENCIES.md +37 -13
  2. package/README.md +421 -32
  3. package/claude/CLAUDE.md +3 -3
  4. package/claude/agents/frontier.md +1 -1
  5. package/claude/agents/sweep.md +3 -3
  6. package/claude/agents/workhorse.md +2 -2
  7. package/claude/guides/claude-prompting.md +72 -39
  8. package/claude/guides/cli-multi-model-workflow.md +33 -15
  9. package/claude/guides/gpt-prompting.md +103 -39
  10. package/claude/guides/review-request.md +27 -0
  11. package/claude/guides/session-distill-workflow.md +54 -2
  12. package/claude/guides/slide-writing/RUNBOOK.md +137 -0
  13. package/claude/guides/slide-writing/scripts/pair.py +979 -0
  14. package/claude/guides/slide-writing/scripts/render.mjs +82 -0
  15. package/claude/guides/slide-writing.md +195 -0
  16. package/claude/guides/svg-visualization-guide.md +9 -0
  17. package/claude/guides/tooling-gotchas.md +1 -1
  18. package/claude/guides/verification-discipline.md +5 -1
  19. package/claude/hooks/tooling-gotchas-hook.py +9 -14
  20. package/claude/skills/understand/SKILL.md +83 -0
  21. package/codex/AGENTS.md +3 -3
  22. package/codex/agents/frontier.toml +2 -1
  23. package/codex/agents/reviewer.toml +1 -1
  24. package/codex/agents/sweep.toml +3 -3
  25. package/codex/agents/workhorse.toml +1 -1
  26. package/codex/config-additions.toml +1 -1
  27. package/codex/guides/claude-prompting.md +72 -39
  28. package/codex/guides/cli-multi-model-workflow.md +33 -15
  29. package/codex/guides/gpt-prompting.md +103 -39
  30. package/codex/guides/review-request.md +27 -0
  31. package/codex/guides/session-distill-workflow.md +54 -2
  32. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  33. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  34. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  35. package/codex/guides/slide-writing.md +195 -0
  36. package/codex/guides/svg-visualization-guide.md +9 -0
  37. package/codex/guides/tooling-gotchas.md +1 -1
  38. package/codex/guides/verification-discipline.md +5 -1
  39. package/compose/assemble.py +290 -14
  40. package/compose/bootstrap/SKILL.md +129 -0
  41. package/compose/check-domains.py +102 -9
  42. package/compose/corpus-state.py +4 -0
  43. package/compose/corpus.py +387 -0
  44. package/compose/corpus_catalog.py +931 -0
  45. package/compose/corpus_install.py +1670 -0
  46. package/compose/corpus_session.py +825 -0
  47. package/compose/corpus_store.py +1423 -0
  48. package/compose/corpus_transaction.py +256 -0
  49. package/compose/corpus_ui.py +655 -0
  50. package/compose/corpus_understand.py +522 -0
  51. package/compose/domains.json +102 -100
  52. package/compose/register-hooks.py +6 -8
  53. package/install.sh +84 -18
  54. package/launch/agent-launch.py +1413 -193
  55. package/launch/agent-launch.toml +12 -16
  56. package/launch/agent-launch.zsh +16 -2
  57. package/launch/i18n/en.toml +135 -7
  58. package/launch/i18n/ja.toml +135 -7
  59. package/launch/i18n/ko.toml +135 -7
  60. package/launch/shell_integration.py +267 -0
  61. package/learn/collect-learning.py +46 -19
  62. package/learn/migrate-learnings.py +10 -1
  63. package/package.json +13 -3
  64. package/provenance.json +1 -1
  65. package/session-cost.py +22 -2
  66. package/wrappers/codex-helm.sh +3 -3
@@ -0,0 +1,825 @@
1
+ """Per-call corpus delivery and durable host-session bindings.
2
+
3
+ Native homes stay native. The only files this module writes are private activation
4
+ records and pins; no global instructions, auth, or discovery registration is copied.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import queue
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ import uuid
19
+ import unicodedata
20
+
21
+
22
+ class SessionError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def _global_instruction_choice(host, include_global_instructions):
27
+ if type(include_global_instructions) is not bool:
28
+ raise SessionError('include_global_instructions must be a boolean')
29
+ if not include_global_instructions and host != 'claude':
30
+ raise SessionError(
31
+ 'excluding global instruction files is not supported by the current Codex adapter; '
32
+ 'keep global instructions included. The native CLI has no selective source control, '
33
+ 'and the OS-level workaround interferes with its execution sandbox'
34
+ )
35
+ return include_global_instructions
36
+
37
+
38
+ def _claude_global_instruction_patterns(env, cwd, check_paths=True):
39
+ """Match Claude's user-scope lookup without redirecting its configuration home."""
40
+ configured = env.get('CLAUDE_CONFIG_DIR')
41
+ if configured is None:
42
+ home = env.get('HOME')
43
+ if home is None:
44
+ import pwd
45
+ home = pwd.getpwuid(os.getuid()).pw_dir
46
+ if not isinstance(home, str) or not home or not pathlib.Path(home).is_absolute():
47
+ raise SessionError('global instruction exclusion requires an absolute native home; keep inclusion enabled')
48
+ configured = str(pathlib.Path(home) / '.claude')
49
+ if not isinstance(configured, str) or not configured or not pathlib.Path(configured).is_absolute():
50
+ raise SessionError(
51
+ 'global instruction exclusion does not support an empty or relative CLAUDE_CONFIG_DIR; '
52
+ 'keep inclusion enabled without changing the existing login/configuration identity'
53
+ )
54
+ path = pathlib.Path(os.path.normpath(unicodedata.normalize('NFC', configured))) / 'CLAUDE.md'
55
+ # Claude's exclusion API uses globs and adds resolved-path variants. Restrict
56
+ # the adapter to literal, unambiguous roots so it cannot exclude project files.
57
+ if any(char in str(path) for char in '*?[]{}()!+@|\\'):
58
+ raise SessionError('global instruction exclusion does not support glob metacharacters in the config path; keep inclusion enabled')
59
+ rules = path.parent / 'rules'
60
+ if check_paths:
61
+ if any(part.is_symlink() for part in (path, *path.parents)):
62
+ raise SessionError('global instruction exclusion does not support a symlinked global instruction path; keep inclusion enabled')
63
+ if rules.is_symlink() or (rules.is_dir() and any(member.is_symlink() for member in rules.rglob('*'))):
64
+ raise SessionError('global instruction exclusion does not support symlinked user rules; keep inclusion enabled')
65
+ directory = pathlib.Path(cwd).resolve()
66
+ parents = (directory, *directory.parents)
67
+ project_root = next((parent for parent in parents if (parent / '.git').exists()), directory)
68
+ for parent in parents:
69
+ for relative in ('CLAUDE.md', 'CLAUDE.local.md', '.claude/CLAUDE.md'):
70
+ project = parent / relative
71
+ if project == path:
72
+ if directory.is_relative_to(path.parent) or path.is_relative_to(project_root):
73
+ raise SessionError(f'global instructions also belong to this project: {path}; keep inclusion enabled')
74
+ elif project.is_file() and (
75
+ (path.is_file() and project.samefile(path)) or project.resolve().is_relative_to(rules)
76
+ ):
77
+ raise SessionError(f'global and project instructions refer to the same file: {project}; keep inclusion enabled')
78
+ # User rules are a separate native instruction source, not imports of CLAUDE.md.
79
+ return [str(path), str(rules) + '/**']
80
+
81
+
82
+ def _claude_exclusion_version(command, cwd, env):
83
+ try:
84
+ result = subprocess.run([command, '--version'], cwd=cwd, env=env, input='',
85
+ capture_output=True, text=True, timeout=10)
86
+ except (OSError, subprocess.TimeoutExpired) as exc:
87
+ raise SessionError('could not verify Claude support for global instruction exclusion; keep inclusion enabled') from exc
88
+ version = re.match(r'^(\d+)\.(\d+)\.(\d+)(?:\s|$)', result.stdout.strip())
89
+ if result.returncode or version is None or tuple(map(int, version.groups())) < (2, 1, 263):
90
+ raise SessionError('global instruction exclusion requires Claude Code 2.1.263 or newer; keep inclusion enabled')
91
+
92
+
93
+ def _settings_values(argv):
94
+ if not isinstance(argv, (list, tuple)) or not all(isinstance(token, str) for token in argv):
95
+ raise SessionError('global instruction exclusion requires a valid argument list')
96
+ options = argv[:argv.index('--')] if '--' in argv else argv
97
+ values = []
98
+ for index, token in enumerate(options):
99
+ if token == '--settings':
100
+ values.append(options[index + 1] if index + 1 < len(options) else None)
101
+ elif token.startswith('--settings='):
102
+ values.append(token.split('=', 1)[1])
103
+ return values
104
+
105
+
106
+ def _record_instruction_choice(record, env, check_paths=True):
107
+ include = _global_instruction_choice(record['host'], record.get('include_global_instructions', True))
108
+ if not include:
109
+ values = _settings_values(record['argv'])
110
+ expected = {'claudeMdExcludes': _claude_global_instruction_patterns(env, record['cwd'], check_paths)}
111
+ try:
112
+ valid = len(values) == 1 and json.loads(values[0]) == expected
113
+ except (TypeError, ValueError):
114
+ valid = False
115
+ if not valid:
116
+ raise SessionError('session pin does not carry its declared global instruction exclusion; start a new session')
117
+ return include
118
+
119
+
120
+ def atomic_json(path, data):
121
+ path = pathlib.Path(path)
122
+ if path.parent.is_symlink():
123
+ raise SessionError(f"refusing symlink directory: {path.parent}")
124
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
125
+ if path.is_symlink():
126
+ raise SessionError(f"refusing symlink: {path}")
127
+ tmp = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp')
128
+ try:
129
+ with open(tmp, 'x', encoding='utf-8') as out:
130
+ os.chmod(tmp, 0o600)
131
+ json.dump(data, out, ensure_ascii=False, indent=2)
132
+ out.write('\n')
133
+ out.flush()
134
+ os.fsync(out.fileno())
135
+ os.replace(tmp, path)
136
+ fd = os.open(path.parent, os.O_RDONLY)
137
+ try:
138
+ os.fsync(fd)
139
+ finally:
140
+ os.close(fd)
141
+ finally:
142
+ tmp.unlink(missing_ok=True)
143
+
144
+
145
+ class CodexServer:
146
+ """One owned stdio server. No turn/start or model generation is used."""
147
+
148
+ def __init__(self, command, config_args=(), cwd=None, env=None):
149
+ self.command, self.config_args, self.cwd, self.env = command, config_args, cwd, env
150
+ self.sequence = 0
151
+ self.events = queue.Queue()
152
+
153
+ def __enter__(self):
154
+ self.proc = subprocess.Popen(
155
+ [self.command, *self.config_args, 'app-server', '--stdio'],
156
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
157
+ cwd=self.cwd, env=self.env, text=True, encoding='utf-8',
158
+ )
159
+ self.reader = threading.Thread(target=self._read, daemon=True)
160
+ self.reader.start()
161
+ try:
162
+ self.call('initialize', {'clientInfo': {'name': 'agent-bios', 'version': '1'},
163
+ 'capabilities': {'experimentalApi': True}})
164
+ self.proc.stdin.write('{"method":"initialized"}\n')
165
+ self.proc.stdin.flush()
166
+ return self
167
+ except BaseException:
168
+ self.__exit__(None, None, None)
169
+ raise
170
+
171
+ def _read(self):
172
+ try:
173
+ for line in self.proc.stdout:
174
+ try:
175
+ self.events.put(json.loads(line))
176
+ except ValueError:
177
+ self.events.put({'transport_error': 'invalid app-server JSON'})
178
+ finally:
179
+ self.events.put({'transport_error': 'app-server closed before its response'})
180
+
181
+ def call(self, method, params, timeout=45):
182
+ self.sequence += 1
183
+ request_id = self.sequence
184
+ self.proc.stdin.write(json.dumps({'id': request_id, 'method': method, 'params': params}) + '\n')
185
+ self.proc.stdin.flush()
186
+ deadline = time.monotonic() + timeout
187
+ while time.monotonic() < deadline:
188
+ try:
189
+ message = self.events.get(timeout=max(0.01, deadline - time.monotonic()))
190
+ except queue.Empty as exc:
191
+ raise SessionError(f"app-server timed out: {method}") from exc
192
+ if 'transport_error' in message:
193
+ raise SessionError(message['transport_error'])
194
+ if message.get('id') != request_id:
195
+ continue
196
+ if 'error' in message:
197
+ raise SessionError(f"app-server {method}: {message['error'].get('message', 'refused')}")
198
+ return message['result']
199
+ raise SessionError(f"app-server timed out: {method}")
200
+
201
+ def __exit__(self, *unused):
202
+ if self.proc.stdin:
203
+ self.proc.stdin.close()
204
+ try:
205
+ self.proc.wait(timeout=5)
206
+ except subprocess.TimeoutExpired:
207
+ self.proc.terminate()
208
+ try:
209
+ self.proc.wait(timeout=5)
210
+ except subprocess.TimeoutExpired:
211
+ self.proc.kill()
212
+ self.proc.wait(timeout=5)
213
+ if self.proc.stdout:
214
+ self.proc.stdout.close()
215
+ self.reader.join(timeout=2)
216
+
217
+
218
+ def config_flags(argv, exclude_developer=False):
219
+ argv = argv[:argv.index('--')] if '--' in argv else argv
220
+ result = []
221
+ index = 0
222
+ while index < len(argv):
223
+ token = argv[index]
224
+ if token in ('--enable', '--disable'):
225
+ if index + 1 >= len(argv):
226
+ raise SessionError(f"missing value for {token}")
227
+ result += ['-c', f"features.{argv[index + 1]}={'true' if token == '--enable' else 'false'}"]
228
+ index += 2
229
+ continue
230
+ if token.startswith(('--enable=', '--disable=')):
231
+ name, feature = token.split('=', 1)
232
+ result += ['-c', f"features.{feature}={'true' if name == '--enable' else 'false'}"]
233
+ if token in ('-c', '--config'):
234
+ if index + 1 >= len(argv):
235
+ raise SessionError(f"missing value for {token}")
236
+ value = argv[index + 1]
237
+ if not (exclude_developer and value.split('=', 1)[0] == 'developer_instructions'):
238
+ result += ['-c', value]
239
+ index += 2
240
+ continue
241
+ if token.startswith('--config='):
242
+ value = token[len('--config='):]
243
+ if not (exclude_developer and value.split('=', 1)[0] == 'developer_instructions'):
244
+ result += ['-c', value]
245
+ if token in ('-p', '--profile') and index + 1 < len(argv):
246
+ result += ['--profile', argv[index + 1]]
247
+ index += 1
248
+ if token.startswith('--profile='):
249
+ result += ['--profile', token.split('=', 1)[1]]
250
+ index += 1
251
+ return result
252
+
253
+
254
+ def named_profile(argv):
255
+ """Return the one native profile requested by a Codex argv, if any.
256
+
257
+ Profiles are a top-level Codex runtime feature. The app-server used below
258
+ for ``config/read`` deliberately has no profile input, so treating a
259
+ profile as ``-c profile=...`` either changes its meaning or makes the
260
+ server reject the request. Keep this parser narrow and fail before a host
261
+ process is started rather than silently reading the base configuration.
262
+ """
263
+ profile = None
264
+ index = 0
265
+ while index < len(argv):
266
+ token = argv[index]
267
+ if token in ('-p', '--profile'):
268
+ if index + 1 >= len(argv):
269
+ raise SessionError(f'missing value for {token}')
270
+ value = argv[index + 1]
271
+ index += 2
272
+ elif token.startswith('--profile='):
273
+ value = token.split('=', 1)[1]
274
+ index += 1
275
+ else:
276
+ index += 1
277
+ continue
278
+ if not value:
279
+ raise SessionError('invalid empty Codex profile')
280
+ if profile is not None and profile != value:
281
+ raise SessionError('multiple Codex profiles are not supported')
282
+ profile = value
283
+ return profile
284
+
285
+
286
+ def replace_developer(argv, text):
287
+ boundary = argv.index('--') if '--' in argv else len(argv)
288
+ argv, tail = argv[:boundary], argv[boundary:]
289
+ result, index = [], 0
290
+ while index < len(argv):
291
+ token = argv[index]
292
+ if token in ('-c', '--config') and index + 1 < len(argv):
293
+ if argv[index + 1].split('=', 1)[0] == 'developer_instructions':
294
+ index += 2
295
+ continue
296
+ if token.startswith('--config=developer_instructions='):
297
+ index += 1
298
+ continue
299
+ result.append(token)
300
+ index += 1
301
+ return [*result, '-c', 'developer_instructions=' + json.dumps(text), *tail]
302
+
303
+
304
+ def instruction_value(argv, host):
305
+ argv = list(argv[:argv.index('--')]) if '--' in argv else argv
306
+ if host == 'claude':
307
+ values = [argv[i + 1] for i, a in enumerate(argv[:-1]) if a == '--append-system-prompt']
308
+ return '\n\n'.join(values)
309
+ for i, a in enumerate(argv[:-1]):
310
+ if a in ('-c', '--config') and argv[i + 1].startswith('developer_instructions='):
311
+ return json.loads(argv[i + 1].split('=', 1)[1])
312
+ return ''
313
+
314
+
315
+ def _native_assets(snapshot):
316
+ assets = snapshot.get('assets') or {}
317
+ if not isinstance(assets, dict) or set(assets) - {'claude_plugins', 'codex_hooks'}:
318
+ raise SessionError('invalid native snapshot assets')
319
+ return assets
320
+
321
+
322
+ def _claude_plugin_paths(snapshot):
323
+ assets = _native_assets(snapshot)
324
+ values = assets.get('claude_plugins', [])
325
+ if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
326
+ raise SessionError('invalid native plugin paths')
327
+ if not values:
328
+ return []
329
+ root = pathlib.Path(snapshot['path']).resolve()
330
+ result = []
331
+ for value in values:
332
+ relative = pathlib.PurePosixPath(value)
333
+ if relative.is_absolute() or '..' in relative.parts or relative.as_posix() != value:
334
+ raise SessionError('native plugin is outside the pinned snapshot')
335
+ path = root / relative
336
+ if not path.resolve().is_relative_to(root) or path == root:
337
+ raise SessionError('native plugin is outside the pinned snapshot')
338
+ result.append(str(path))
339
+ if len(result) != len(set(result)):
340
+ raise SessionError('duplicate native plugin')
341
+ return result
342
+
343
+
344
+ def _codex_hook_config(snapshot):
345
+ from corpus_catalog import CatalogError, validate_native_hook_config
346
+ hooks = _native_assets(snapshot).get('codex_hooks', {})
347
+ try:
348
+ validate_native_hook_config(hooks, 'codex')
349
+ except CatalogError as exc:
350
+ raise SessionError(f'invalid native snapshot hooks: {exc}') from exc
351
+ return hooks
352
+
353
+
354
+ def _toml_value(value):
355
+ """Serialize the JSON values used by hook config as TOML inline values."""
356
+ if isinstance(value, str):
357
+ return json.dumps(value, ensure_ascii=False)
358
+ if isinstance(value, bool):
359
+ return str(value).lower()
360
+ if isinstance(value, (int, float)):
361
+ return str(value)
362
+ if isinstance(value, list):
363
+ return '[' + ', '.join(_toml_value(item) for item in value) + ']'
364
+ if isinstance(value, dict):
365
+ return '{' + ', '.join(_toml_value(key) + ' = ' + _toml_value(item)
366
+ for key, item in value.items()) + '}'
367
+ raise SessionError('hook config contains a value that TOML cannot represent')
368
+
369
+
370
+ def _with_codex_hooks(argv, hooks, config):
371
+ if not hooks:
372
+ return argv
373
+ # Codex loads every config layer's hooks independently. Copy only existing
374
+ # session-flag groups; copying effective user/project groups would run them twice.
375
+ layers = config.get('layers')
376
+ if not isinstance(layers, list):
377
+ raise SessionError('Codex native hooks require config/read layer provenance')
378
+ session_layers = [layer for layer in layers if layer.get('name', {}).get('type') == 'sessionFlags']
379
+ if len(session_layers) > 1:
380
+ raise SessionError('Codex reported ambiguous session hook configuration')
381
+ existing = session_layers[0].get('config', {}).get('hooks', {}) if session_layers else {}
382
+ if not isinstance(existing, dict):
383
+ raise SessionError('Codex session hooks are not an event mapping')
384
+ flags = []
385
+ for event, groups in sorted(hooks.items()):
386
+ prior = existing.get(event, [])
387
+ if not isinstance(prior, list):
388
+ raise SessionError(f'Codex session hooks.{event} is not a matcher list')
389
+ flags += ['-c', f'hooks.{event}={_toml_value(prior + groups)}']
390
+ boundary = argv.index('--') if '--' in argv else len(argv)
391
+ return [*argv[:boundary], *flags, *argv[boundary:]]
392
+
393
+
394
+ def validate_codex_hooks(command, snapshot, argv, cwd, env):
395
+ """Prove discovery on this runtime; preserve native enablement and trust."""
396
+ hooks = _codex_hook_config(snapshot)
397
+ if not hooks:
398
+ return
399
+ expected = {(event[0].lower() + event[1:], group['matcher'], handler['command'])
400
+ for event, groups in hooks.items() for group in groups for handler in group['hooks']}
401
+ with CodexServer(command, config_flags(argv), cwd, env) as server:
402
+ result = server.call('hooks/list', {'cwds': [str(cwd)]})
403
+ config = server.call('config/read', {'cwd': str(cwd), 'includeLayers': False})
404
+ rows = result.get('data')
405
+ if not isinstance(rows, list) or len(rows) != 1 or rows[0].get('errors'):
406
+ raise SessionError('Codex could not discover the selected session hooks')
407
+ found = {(row.get('eventName'), row.get('matcher'), row.get('command')): row
408
+ for row in rows[0].get('hooks', []) if row.get('source') == 'sessionFlags'}
409
+ if not expected.issubset(found):
410
+ raise SessionError('Codex did not discover every selected session hook; check host hook policy and runtime support')
411
+ if config.get('config', {}).get('features', {}).get('hooks') is False:
412
+ print('agent-bios: selected Codex hooks are disabled by the effective native hooks feature setting.', file=sys.stderr)
413
+ pending = sum(not found[key].get('enabled') or found[key].get('trustStatus') != 'trusted'
414
+ for key in expected)
415
+ if pending:
416
+ print(f'agent-bios: {pending} selected Codex hook(s) need native review or enablement; '
417
+ 'open /hooks in this session. Registration does not establish execution.', file=sys.stderr)
418
+
419
+
420
+ def validate_claude_plugins(command, snapshot, cwd, env):
421
+ """Native validation is manifest-only; assert actual carriers separately."""
422
+ for raw in _claude_plugin_paths(snapshot):
423
+ root = pathlib.Path(raw)
424
+ agents = list((root / 'agents').glob('*.md'))
425
+ hooks = root / 'hooks/hooks.json'
426
+ if bool(agents) == hooks.is_file():
427
+ raise SessionError('native item plugin must have one agent or hook carrier')
428
+ if agents and len(agents) != 1:
429
+ raise SessionError('native item plugin has unexpected agents')
430
+ completed = subprocess.run([command, 'plugin', 'validate', '--json', str(root)],
431
+ cwd=cwd, env=env, input='', capture_output=True, text=True, timeout=30)
432
+ try:
433
+ result = json.loads(completed.stdout)
434
+ except ValueError as exc:
435
+ raise SessionError('Claude must support plugin validate --json for native corpus activation') from exc
436
+ manifest = result.get('manifest') if isinstance(result, dict) else None
437
+ if completed.returncode or not isinstance(result, dict) or result.get('success') is not True or not isinstance(manifest, dict) or manifest.get('errors') != []:
438
+ raise SessionError(f'Claude rejected native corpus plugin {root.name}: {completed.stdout[-1500:]}')
439
+ for warning in manifest.get('warnings', []):
440
+ print(f"agent-bios: native plugin warning: {warning.get('message', warning)}", file=sys.stderr)
441
+
442
+
443
+ def _verified_launch_snapshot(state_root, snapshot):
444
+ from corpus_store import verify_snapshot
445
+ ref = snapshot.get('content_ref')
446
+ if not isinstance(ref, str) or not re.fullmatch('[0-9a-f]{64}', ref):
447
+ raise SessionError('invalid corpus content ref')
448
+ root = pathlib.Path(state_root) / 'sessions/snapshots' / ref
449
+ if pathlib.Path(snapshot.get('path', '')).resolve() != root.resolve():
450
+ raise SessionError('activation snapshot is not owned by this private store')
451
+ verified = verify_snapshot(root, ref)
452
+ return {**verified['output'], 'path': str(root), 'content_ref': ref,
453
+ 'assets': verified['output'].get('assets', {})}
454
+
455
+
456
+ def compose_argv(command, argv, host, snapshot, cwd=None, env=None, include_global_instructions=True):
457
+ """Preserve native instructions before the selected corpus and launch contract."""
458
+ _global_instruction_choice(host, include_global_instructions)
459
+ native_env = dict(os.environ if env is None else env)
460
+ native_cwd = pathlib.Path(cwd or pathlib.Path.cwd()).resolve()
461
+ exclusion = []
462
+ if not include_global_instructions:
463
+ if _settings_values(argv):
464
+ raise SessionError(
465
+ 'global instruction exclusion cannot be combined with an existing --settings argument: '
466
+ 'Claude would replace it; keep inclusion enabled to preserve those settings'
467
+ )
468
+ patterns = _claude_global_instruction_patterns(native_env, native_cwd)
469
+ _claude_exclusion_version(command, native_cwd, native_env)
470
+ exclusion = ['--settings', json.dumps({'claudeMdExcludes': patterns}, ensure_ascii=False)]
471
+ content = snapshot['instruction_text']
472
+ contract = instruction_value(argv, host)
473
+ if host == 'codex':
474
+ profile = named_profile(argv)
475
+ if profile is not None:
476
+ raise SessionError(
477
+ f'named Codex profile {profile!r} cannot be used with private corpus activation: '
478
+ 'the native app-server config/read route does not accept --profile, so its '
479
+ 'effective developer instructions cannot be read without rebuilding native '
480
+ 'profile semantics'
481
+ )
482
+ hooks = _codex_hook_config(snapshot)
483
+ with CodexServer(command, config_flags(argv, exclude_developer=True), cwd, env) as server:
484
+ config = server.call('config/read', {'cwd': str(cwd or pathlib.Path.cwd()), 'includeLayers': bool(hooks)})
485
+ native = config.get('config', {}).get('developer_instructions') or ''
486
+ if not isinstance(native, str):
487
+ raise SessionError('effective developer_instructions is not text')
488
+ result = replace_developer(argv, '\n\n'.join(x for x in (native, content, contract) if x))
489
+ return _with_codex_hooks(result, hooks, config)
490
+ boundary = argv.index('--') if '--' in argv else len(argv)
491
+ options, tail = argv[:boundary], argv[boundary:]
492
+ result, index = [], 0
493
+ while index < len(options):
494
+ if options[index] == '--append-system-prompt':
495
+ index += 2
496
+ else:
497
+ result.append(options[index])
498
+ index += 1
499
+ for path in _claude_plugin_paths(snapshot):
500
+ result += ['--plugin-dir', path]
501
+ return [*result, *exclusion, '--append-system-prompt', '\n\n'.join(x for x in (content, contract) if x), *tail]
502
+
503
+
504
+ def session_paths(state_root):
505
+ root = pathlib.Path(state_root)
506
+ for relative in ('', 'runtime', 'runtime/activations', 'sessions', 'sessions/pins',
507
+ 'sessions/pins/codex', 'sessions/pins/claude', 'sessions/snapshots'):
508
+ if (root / relative).is_symlink():
509
+ raise SessionError(f'refusing symlink state directory: {root / relative}')
510
+ return root / 'runtime' / 'activations', root / 'sessions' / 'pins'
511
+
512
+
513
+ def validate_session_id(value):
514
+ if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{3,127}', value):
515
+ raise SessionError('invalid host session id')
516
+ return value
517
+
518
+
519
+ def native_home(host, env, cwd=None):
520
+ key, default = ('CODEX_HOME', '.codex') if host == 'codex' else ('CLAUDE_CONFIG_DIR', '.claude')
521
+ base = pathlib.Path(cwd or pathlib.Path.cwd())
522
+ configured = env.get(key)
523
+ if configured:
524
+ path = pathlib.Path(configured)
525
+ else:
526
+ home = pathlib.Path(env.get('HOME', str(pathlib.Path.home())))
527
+ path = home / default
528
+ if not path.is_absolute():
529
+ path = base / path
530
+ return path.resolve()
531
+
532
+
533
+ def environment_provenance(host, env):
534
+ """Capture only the native-home representation, never credential contents."""
535
+ if host not in ('codex', 'claude'):
536
+ raise SessionError('unknown host')
537
+ key = 'CODEX_HOME' if host == 'codex' else 'CLAUDE_CONFIG_DIR'
538
+ value = env.get(key)
539
+ if key in env and not isinstance(value, str):
540
+ raise SessionError(f'invalid {key} environment value')
541
+ variables = {key: {'state': 'set', 'value': value} if key in env else {'state': 'unset'}}
542
+ # HOME affects a native default when the host variable is absent or empty.
543
+ if value in (None, ''):
544
+ home = env.get('HOME')
545
+ if 'HOME' in env and not isinstance(home, str):
546
+ raise SessionError('invalid HOME environment value')
547
+ variables['HOME'] = {'state': 'set', 'value': home} if 'HOME' in env else {'state': 'unset'}
548
+ return {'schema_version': 1, 'variables': variables}
549
+
550
+
551
+ def restore_environment(record, env):
552
+ """Restore an exact captured set/unset native-home representation."""
553
+ host = record.get('host')
554
+ key = 'CODEX_HOME' if host == 'codex' else 'CLAUDE_CONFIG_DIR' if host == 'claude' else None
555
+ provenance = record.get('environment')
556
+ if key is None or not isinstance(provenance, dict) or set(provenance) != {'schema_version', 'variables'} or type(provenance.get('schema_version')) is not int or provenance['schema_version'] != 1:
557
+ raise SessionError('pin lacks environment provenance; start a new activated session or run an explicit future migration')
558
+ variables = provenance.get('variables')
559
+ if not isinstance(variables, dict) or key not in variables:
560
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
561
+ host_entry = variables[key]
562
+ if not isinstance(host_entry, dict):
563
+ raise SessionError('pin has invalid native-home environment entry')
564
+ needs_home = host_entry.get('state') == 'unset' or host_entry.get('value') == ''
565
+ if set(variables) != ({key, 'HOME'} if needs_home else {key}):
566
+ raise SessionError('pin has incomplete or unknown native-home environment entries')
567
+ restored = dict(env)
568
+ for name in (key, 'HOME'):
569
+ if name not in variables:
570
+ continue
571
+ entry = variables[name]
572
+ if not isinstance(entry, dict) or set(entry) - {'state', 'value'} or entry.get('state') not in {'set', 'unset'}:
573
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
574
+ if entry['state'] == 'unset':
575
+ if 'value' in entry:
576
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
577
+ restored.pop(name, None)
578
+ elif not isinstance(entry.get('value'), str):
579
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
580
+ else:
581
+ restored[name] = entry['value']
582
+ return restored
583
+
584
+
585
+ def prepare(state_root, host, snapshot, argv, cwd=None, env=None, include_global_instructions=True):
586
+ if host not in ('codex', 'claude'):
587
+ raise SessionError('unknown host')
588
+ _global_instruction_choice(host, include_global_instructions)
589
+ intents, _ = session_paths(state_root)
590
+ cwd = pathlib.Path(cwd or pathlib.Path.cwd()).resolve()
591
+ intent_id = uuid.uuid4().hex
592
+ record = {'schema_version': 1, 'intent_id': intent_id, 'state': 'PREPARED',
593
+ 'host': host, 'content_ref': snapshot['content_ref'], 'snapshot_path': str(snapshot['path']),
594
+ 'config_home': str(native_home(host, os.environ if env is None else env, cwd)),
595
+ 'environment': environment_provenance(host, os.environ if env is None else env),
596
+ 'include_global_instructions': include_global_instructions,
597
+ 'argv': list(argv), 'cwd': str(cwd or pathlib.Path.cwd()), 'created': time.time()}
598
+ _record_instruction_choice(record, dict(os.environ if env is None else env))
599
+ atomic_json(intents / intent_id / 'journal.json', record)
600
+ return record
601
+
602
+
603
+ def recorded_cwd(record):
604
+ value = record.get('cwd')
605
+ if not isinstance(value, str) or not pathlib.Path(value).is_absolute():
606
+ raise SessionError('session pin lacks an absolute working directory; start a new activated session')
607
+ try:
608
+ canonical = str(pathlib.Path(value).resolve())
609
+ except (OSError, RuntimeError, ValueError) as exc:
610
+ raise SessionError('session working directory cannot be resolved') from exc
611
+ if canonical != value:
612
+ raise SessionError('session working directory is no longer canonical; start a new activated session')
613
+ return value
614
+
615
+
616
+ def observe_and_pin(state_root, record, host_id, evidence):
617
+ if not isinstance(record.get('intent_id'), str) or not re.fullmatch('[0-9a-f]{32}', record['intent_id']):
618
+ raise SessionError('invalid activation journal identity')
619
+ recorded_cwd(record)
620
+ native_env = restore_environment(record, {})
621
+ _record_instruction_choice(record, native_env, check_paths=False)
622
+ host_id = validate_session_id(host_id)
623
+ intents, pins = session_paths(state_root)
624
+ pin_path = pins / record['host'] / f'{host_id}.json'
625
+ if pin_path.exists():
626
+ existing = read_pin(state_root, record['host'], host_id)
627
+ if existing['intent_id'] != record['intent_id'] or existing['content_ref'] != record['content_ref']:
628
+ raise SessionError('host session already belongs to a different corpus activation')
629
+ record.update(state='HOST_OBSERVED', session_id=host_id, evidence=evidence)
630
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
631
+ atomic_json(pin_path, record)
632
+ record['state'] = 'PINNED'
633
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
634
+ return record
635
+
636
+
637
+ def read_pin(state_root, host, session_id):
638
+ if host not in ('codex', 'claude'):
639
+ raise SessionError('unknown host')
640
+ session_id = validate_session_id(session_id)
641
+ _, pins = session_paths(state_root)
642
+ path = pins / host / f'{session_id}.json'
643
+ if path.is_symlink() or not path.is_file():
644
+ raise SessionError(f'no corpus pin for {host} session {session_id}')
645
+ record = json.loads(path.read_text())
646
+ if record.get('host') != host or record.get('session_id') != session_id:
647
+ raise SessionError('session pin identity mismatch')
648
+ recorded_cwd(record)
649
+ native_env = restore_environment(record, {})
650
+ _record_instruction_choice(record, native_env, check_paths=False)
651
+ snapshot = pathlib.Path(record['snapshot_path'])
652
+ allowed = (pathlib.Path(state_root) / 'sessions' / 'snapshots').resolve()
653
+ if not snapshot.resolve().is_relative_to(allowed) or snapshot.is_symlink():
654
+ raise SessionError('session pin names an unowned snapshot')
655
+ if not snapshot.is_dir():
656
+ raise SessionError('pinned corpus snapshot missing; recover it before resuming')
657
+ from corpus_store import verify_snapshot
658
+ verify_snapshot(snapshot, record['content_ref'])
659
+ return record
660
+
661
+
662
+ def claude_evidence(record, env):
663
+ requested = record.get('requested_session_id')
664
+ if not requested:
665
+ return None
666
+ validate_session_id(requested)
667
+ home = native_home('claude', restore_environment(record, env), recorded_cwd(record))
668
+ from corpus_store import _reject_symlink_path
669
+ _reject_symlink_path(home / 'projects')
670
+ for trace in (home / 'projects').glob(f'*/{requested}.jsonl'):
671
+ _reject_symlink_path(trace.parent)
672
+ _reject_symlink_path(trace)
673
+ with trace.open(encoding='utf-8') as source:
674
+ for line in source:
675
+ try:
676
+ event = json.loads(line)
677
+ except ValueError:
678
+ continue
679
+ if isinstance(event, dict) and event.get('sessionId') == requested:
680
+ if event.get('cwd') and pathlib.Path(event['cwd']).resolve() != pathlib.Path(record['cwd']).resolve():
681
+ continue
682
+ return {'method': 'native-session-log', 'path': str(trace)}
683
+ return None
684
+
685
+
686
+ def recover_activations(state_root, command=None, host=None, env=None):
687
+ """Reconcile exact recorded ids against host evidence; retain unresolved intents."""
688
+ env = dict(os.environ if env is None else env)
689
+ intents, _ = session_paths(state_root)
690
+ result = {'pinned': [], 'pending': []}
691
+ for path in sorted(intents.glob('*/journal.json')):
692
+ if path.is_symlink() or path.parent.is_symlink():
693
+ raise SessionError('symlink activation journal')
694
+ record = json.loads(path.read_text())
695
+ if not isinstance(record, dict) or record.get('intent_id') != path.parent.name or not re.fullmatch('[0-9a-f]{32}', path.parent.name):
696
+ raise SessionError('activation journal identity does not match its owned directory')
697
+ if record.get('state') in ('PREPARED', 'HOST_OBSERVED'):
698
+ try:
699
+ recorded_cwd(record)
700
+ native_env = restore_environment(record, env)
701
+ _record_instruction_choice(record, native_env, check_paths=False)
702
+ except SessionError as exc:
703
+ print(f"agent-bios: activation {record.get('intent_id', path.parent.name)} remains pending: {exc}", file=sys.stderr)
704
+ result['pending'].append(record.get('intent_id', path.parent.name))
705
+ continue
706
+ if record.get('state') == 'HOST_OBSERVED':
707
+ observe_and_pin(state_root, record, record['session_id'], record['evidence'])
708
+ result['pinned'].append(record['session_id'])
709
+ elif record.get('state') == 'PREPARED':
710
+ evidence = None
711
+ requested = record.get('requested_session_id')
712
+ if requested and record['host'] == 'claude':
713
+ try:
714
+ evidence = claude_evidence(record, env)
715
+ except SessionError as exc:
716
+ print(f"agent-bios: activation {record.get('intent_id', '?')} remains pending: {exc}", file=sys.stderr)
717
+ elif requested and record['host'] == 'codex' and host == 'codex' and command:
718
+ validate_session_id(requested)
719
+ try:
720
+ native_env = restore_environment(record, env)
721
+ with CodexServer(command, config_flags(record['argv']), record['cwd'], native_env) as server:
722
+ observed = server.call('thread/read', {'threadId': requested, 'includeTurns': False})
723
+ if observed.get('thread', {}).get('id') == requested:
724
+ evidence = {'method': 'recovered-thread/read'}
725
+ except SessionError as exc:
726
+ print(f"agent-bios: activation {record['intent_id']} remains pending: {exc}", file=sys.stderr)
727
+ if evidence:
728
+ observe_and_pin(state_root, record, requested, evidence)
729
+ result['pinned'].append(requested)
730
+ else:
731
+ result['pending'].append(record['intent_id'])
732
+ return result
733
+
734
+
735
+ def create_codex_session(command, argv, state_root, record, cwd, env):
736
+ with CodexServer(command, config_flags(argv), cwd, env) as server:
737
+ params = {'cwd': str(cwd), 'developerInstructions': instruction_value(argv, 'codex'),
738
+ 'ephemeral': False, 'experimentalRawEvents': False}
739
+ for index, token in enumerate(argv[:-1]):
740
+ if token in ('--model', '-m'):
741
+ params['model'] = argv[index + 1]
742
+ result = server.call('thread/start', params)
743
+ thread = result['thread']
744
+ host_id = validate_session_id(thread['id'])
745
+ # thread/start alone may return an id before persisting a resumable thread.
746
+ # Retain the exact id in PREPARED before recording a small developer item;
747
+ # inject_items persists history without generating a model turn.
748
+ record['requested_session_id'] = host_id
749
+ intents, _ = session_paths(state_root)
750
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
751
+ server.call('thread/inject_items', {'threadId': host_id, 'items': [
752
+ {'type': 'message', 'role': 'developer', 'content': [
753
+ {'type': 'input_text', 'text': f"agent-bios session snapshot: {record['content_ref']}"}]}]})
754
+ with CodexServer(command, config_flags(argv), cwd, env) as server:
755
+ persisted = server.call('thread/read', {'threadId': host_id, 'includeTurns': False})
756
+ if persisted.get('thread', {}).get('id') != host_id:
757
+ raise SessionError('host did not persist the requested corpus session')
758
+ observe_and_pin(state_root, record, host_id,
759
+ {'method': 'thread/start+inject_items+read', 'thread_path': thread.get('path')})
760
+ return host_id
761
+
762
+
763
+ def launch(command, argv, state_root, host, snapshot, cwd=None, env=None, resume_id=None,
764
+ include_global_instructions=True):
765
+ """Start a pinned native session, returning its exit status."""
766
+ cwd = pathlib.Path(cwd or pathlib.Path.cwd()).resolve()
767
+ env = dict(os.environ if env is None else env)
768
+ if resume_id and include_global_instructions is not True:
769
+ raise SessionError('resume uses its recorded global instruction choice; omit the override or start a new session')
770
+ if not resume_id:
771
+ _global_instruction_choice(host, include_global_instructions)
772
+ recovered = recover_activations(state_root, command, host, env)
773
+ if recovered['pending']:
774
+ print(f"agent-bios: {len(recovered['pending'])} activation(s) lack host evidence; their snapshots remain retained.", file=sys.stderr)
775
+ if resume_id:
776
+ record = read_pin(state_root, host, resume_id)
777
+ env = restore_environment(record, env)
778
+ if not _record_instruction_choice(record, env):
779
+ _claude_exclusion_version(command, record['cwd'], env)
780
+ pinned = _verified_launch_snapshot(state_root, {'path': record['snapshot_path'], 'content_ref': record['content_ref']})
781
+ if host == 'claude':
782
+ validate_claude_plugins(command, pinned, record['cwd'], env)
783
+ else:
784
+ validate_codex_hooks(command, pinned, record['argv'], record['cwd'], env)
785
+ argv = record['argv']
786
+ native = ['resume', resume_id, *argv] if host == 'codex' else ['--resume', resume_id, *argv]
787
+ return subprocess.call([command, *native], cwd=record['cwd'], env=env)
788
+ snapshot = _verified_launch_snapshot(state_root, snapshot)
789
+ argv = compose_argv(command, argv, host, snapshot, cwd, env, include_global_instructions)
790
+ if host == 'codex':
791
+ validate_codex_hooks(command, snapshot, argv, cwd, env)
792
+ if host == 'claude':
793
+ validate_claude_plugins(command, snapshot, cwd, env)
794
+ record = prepare(state_root, host, snapshot, argv, cwd, env, include_global_instructions)
795
+ if host == 'codex':
796
+ # Create the durable thread before opening the native resume UI. The returned
797
+ # id comes from the real host and is pinned before any user turn is possible.
798
+ create_codex_session(command, argv, state_root, record, cwd, env)
799
+ native = ['resume', record['session_id'], *argv]
800
+ return subprocess.call([command, *native], cwd=cwd, env=env)
801
+ requested_id = str(uuid.uuid4())
802
+ record['requested_session_id'] = requested_id
803
+ intents, _ = session_paths(state_root)
804
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
805
+ native = ['--session-id', requested_id, *argv]
806
+ child = subprocess.Popen([command, *native], cwd=cwd, env=env)
807
+ try:
808
+ while True:
809
+ if record['state'] == 'PREPARED':
810
+ evidence = claude_evidence(record, env)
811
+ if evidence:
812
+ observe_and_pin(state_root, record, requested_id, evidence)
813
+ if child.poll() is not None:
814
+ if record['state'] != 'PINNED':
815
+ print(f"agent-bios: no host session evidence; activation {record['intent_id']} retained for recovery.", file=sys.stderr)
816
+ return child.returncode
817
+ time.sleep(0.15)
818
+ finally:
819
+ if child.poll() is None:
820
+ child.terminate()
821
+ try:
822
+ child.wait(timeout=5)
823
+ except subprocess.TimeoutExpired:
824
+ child.kill()
825
+ child.wait(timeout=5)