agent-bios 0.15.0 → 0.16.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 (59) hide show
  1. package/DEPENDENCIES.md +35 -12
  2. package/README.md +346 -31
  3. package/claude/CLAUDE.md +2 -2
  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/verification-discipline.md +5 -1
  18. package/claude/hooks/tooling-gotchas-hook.py +7 -5
  19. package/codex/AGENTS.md +2 -2
  20. package/codex/agents/frontier.toml +2 -1
  21. package/codex/agents/reviewer.toml +1 -1
  22. package/codex/agents/sweep.toml +3 -3
  23. package/codex/agents/workhorse.toml +1 -1
  24. package/codex/config-additions.toml +1 -1
  25. package/codex/guides/claude-prompting.md +72 -39
  26. package/codex/guides/cli-multi-model-workflow.md +33 -15
  27. package/codex/guides/gpt-prompting.md +103 -39
  28. package/codex/guides/review-request.md +27 -0
  29. package/codex/guides/session-distill-workflow.md +54 -2
  30. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  31. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  32. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  33. package/codex/guides/slide-writing.md +195 -0
  34. package/codex/guides/svg-visualization-guide.md +9 -0
  35. package/codex/guides/verification-discipline.md +5 -1
  36. package/compose/assemble.py +290 -14
  37. package/compose/bootstrap/SKILL.md +119 -0
  38. package/compose/check-domains.py +102 -9
  39. package/compose/corpus-state.py +4 -0
  40. package/compose/corpus.py +387 -0
  41. package/compose/corpus_catalog.py +882 -0
  42. package/compose/corpus_install.py +1617 -0
  43. package/compose/corpus_session.py +726 -0
  44. package/compose/corpus_store.py +1414 -0
  45. package/compose/corpus_transaction.py +236 -0
  46. package/compose/corpus_ui.py +644 -0
  47. package/compose/domains.json +101 -100
  48. package/install.sh +63 -18
  49. package/launch/agent-launch.py +1216 -179
  50. package/launch/agent-launch.toml +12 -16
  51. package/launch/i18n/en.toml +112 -7
  52. package/launch/i18n/ja.toml +112 -7
  53. package/launch/i18n/ko.toml +112 -7
  54. package/learn/collect-learning.py +46 -19
  55. package/learn/migrate-learnings.py +10 -1
  56. package/package.json +11 -3
  57. package/provenance.json +1 -1
  58. package/session-cost.py +22 -2
  59. package/wrappers/codex-helm.sh +3 -3
@@ -0,0 +1,726 @@
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
+ result = []
220
+ index = 0
221
+ while index < len(argv):
222
+ token = argv[index]
223
+ if token in ('-c', '--config'):
224
+ if index + 1 >= len(argv):
225
+ raise SessionError(f"missing value for {token}")
226
+ value = argv[index + 1]
227
+ if not (exclude_developer and value.split('=', 1)[0] == 'developer_instructions'):
228
+ result += ['-c', value]
229
+ index += 2
230
+ continue
231
+ if token.startswith('--config='):
232
+ value = token[len('--config='):]
233
+ if not (exclude_developer and value.split('=', 1)[0] == 'developer_instructions'):
234
+ result += ['-c', value]
235
+ if token in ('-p', '--profile') and index + 1 < len(argv):
236
+ result += ['--profile', argv[index + 1]]
237
+ index += 1
238
+ if token.startswith('--profile='):
239
+ result += ['--profile', token.split('=', 1)[1]]
240
+ index += 1
241
+ return result
242
+
243
+
244
+ def named_profile(argv):
245
+ """Return the one native profile requested by a Codex argv, if any.
246
+
247
+ Profiles are a top-level Codex runtime feature. The app-server used below
248
+ for ``config/read`` deliberately has no profile input, so treating a
249
+ profile as ``-c profile=...`` either changes its meaning or makes the
250
+ server reject the request. Keep this parser narrow and fail before a host
251
+ process is started rather than silently reading the base configuration.
252
+ """
253
+ profile = None
254
+ index = 0
255
+ while index < len(argv):
256
+ token = argv[index]
257
+ if token in ('-p', '--profile'):
258
+ if index + 1 >= len(argv):
259
+ raise SessionError(f'missing value for {token}')
260
+ value = argv[index + 1]
261
+ index += 2
262
+ elif token.startswith('--profile='):
263
+ value = token.split('=', 1)[1]
264
+ index += 1
265
+ else:
266
+ index += 1
267
+ continue
268
+ if not value:
269
+ raise SessionError('invalid empty Codex profile')
270
+ if profile is not None and profile != value:
271
+ raise SessionError('multiple Codex profiles are not supported')
272
+ profile = value
273
+ return profile
274
+
275
+
276
+ def replace_developer(argv, text):
277
+ result, index = [], 0
278
+ while index < len(argv):
279
+ token = argv[index]
280
+ if token in ('-c', '--config') and index + 1 < len(argv):
281
+ if argv[index + 1].split('=', 1)[0] == 'developer_instructions':
282
+ index += 2
283
+ continue
284
+ if token.startswith('--config=developer_instructions='):
285
+ index += 1
286
+ continue
287
+ result.append(token)
288
+ index += 1
289
+ return [*result, '-c', 'developer_instructions=' + json.dumps(text)]
290
+
291
+
292
+ def instruction_value(argv, host):
293
+ argv = list(argv[:argv.index('--')]) if '--' in argv else argv
294
+ if host == 'claude':
295
+ values = [argv[i + 1] for i, a in enumerate(argv[:-1]) if a == '--append-system-prompt']
296
+ return '\n\n'.join(values)
297
+ for i, a in enumerate(argv[:-1]):
298
+ if a in ('-c', '--config') and argv[i + 1].startswith('developer_instructions='):
299
+ return json.loads(argv[i + 1].split('=', 1)[1])
300
+ return ''
301
+
302
+
303
+ def _claude_plugin_paths(snapshot):
304
+ assets = snapshot.get('assets') or {}
305
+ if not isinstance(assets, dict) or set(assets) - {'claude_plugins'}:
306
+ raise SessionError('invalid native snapshot assets')
307
+ values = assets.get('claude_plugins', [])
308
+ if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
309
+ raise SessionError('invalid native plugin paths')
310
+ if not values:
311
+ return []
312
+ root = pathlib.Path(snapshot['path']).resolve()
313
+ result = []
314
+ for value in values:
315
+ relative = pathlib.PurePosixPath(value)
316
+ if relative.is_absolute() or '..' in relative.parts or relative.as_posix() != value:
317
+ raise SessionError('native plugin is outside the pinned snapshot')
318
+ path = root / relative
319
+ if not path.resolve().is_relative_to(root) or path == root:
320
+ raise SessionError('native plugin is outside the pinned snapshot')
321
+ result.append(str(path))
322
+ if len(result) != len(set(result)):
323
+ raise SessionError('duplicate native plugin')
324
+ return result
325
+
326
+
327
+ def validate_claude_plugins(command, snapshot, cwd, env):
328
+ """Native validation is manifest-only; assert actual carriers separately."""
329
+ for raw in _claude_plugin_paths(snapshot):
330
+ root = pathlib.Path(raw)
331
+ agents = list((root / 'agents').glob('*.md'))
332
+ hooks = root / 'hooks/hooks.json'
333
+ if bool(agents) == hooks.is_file():
334
+ raise SessionError('native item plugin must have one agent or hook carrier')
335
+ if agents and len(agents) != 1:
336
+ raise SessionError('native item plugin has unexpected agents')
337
+ completed = subprocess.run([command, 'plugin', 'validate', '--json', str(root)],
338
+ cwd=cwd, env=env, input='', capture_output=True, text=True, timeout=30)
339
+ try:
340
+ result = json.loads(completed.stdout)
341
+ except ValueError as exc:
342
+ raise SessionError('Claude must support plugin validate --json for native corpus activation') from exc
343
+ manifest = result.get('manifest') if isinstance(result, dict) else None
344
+ if completed.returncode or not isinstance(result, dict) or result.get('success') is not True or not isinstance(manifest, dict) or manifest.get('errors') != []:
345
+ raise SessionError(f'Claude rejected native corpus plugin {root.name}: {completed.stdout[-1500:]}')
346
+ for warning in manifest.get('warnings', []):
347
+ print(f"agent-bios: native plugin warning: {warning.get('message', warning)}", file=sys.stderr)
348
+
349
+
350
+ def _verified_launch_snapshot(state_root, snapshot):
351
+ from corpus_store import verify_snapshot
352
+ ref = snapshot.get('content_ref')
353
+ if not isinstance(ref, str) or not re.fullmatch('[0-9a-f]{64}', ref):
354
+ raise SessionError('invalid corpus content ref')
355
+ root = pathlib.Path(state_root) / 'sessions/snapshots' / ref
356
+ if pathlib.Path(snapshot.get('path', '')).resolve() != root.resolve():
357
+ raise SessionError('activation snapshot is not owned by this private store')
358
+ verified = verify_snapshot(root, ref)
359
+ return {**verified['output'], 'path': str(root), 'content_ref': ref,
360
+ 'assets': verified['output'].get('assets', {})}
361
+
362
+
363
+ def compose_argv(command, argv, host, snapshot, cwd=None, env=None, include_global_instructions=True):
364
+ """Preserve native instructions before the selected corpus and launch contract."""
365
+ _global_instruction_choice(host, include_global_instructions)
366
+ native_env = dict(os.environ if env is None else env)
367
+ native_cwd = pathlib.Path(cwd or pathlib.Path.cwd()).resolve()
368
+ exclusion = []
369
+ if not include_global_instructions:
370
+ if _settings_values(argv):
371
+ raise SessionError(
372
+ 'global instruction exclusion cannot be combined with an existing --settings argument: '
373
+ 'Claude would replace it; keep inclusion enabled to preserve those settings'
374
+ )
375
+ patterns = _claude_global_instruction_patterns(native_env, native_cwd)
376
+ _claude_exclusion_version(command, native_cwd, native_env)
377
+ exclusion = ['--settings', json.dumps({'claudeMdExcludes': patterns}, ensure_ascii=False)]
378
+ content = snapshot['instruction_text']
379
+ contract = instruction_value(argv, host)
380
+ if host == 'codex':
381
+ profile = named_profile(argv)
382
+ if profile is not None:
383
+ raise SessionError(
384
+ f'named Codex profile {profile!r} cannot be used with private corpus activation: '
385
+ 'the native app-server config/read route does not accept --profile, so its '
386
+ 'effective developer instructions cannot be read without rebuilding native '
387
+ 'profile semantics'
388
+ )
389
+ with CodexServer(command, config_flags(argv, exclude_developer=True), cwd, env) as server:
390
+ config = server.call('config/read', {'cwd': str(cwd or pathlib.Path.cwd()), 'includeLayers': False})
391
+ native = config.get('config', {}).get('developer_instructions') or ''
392
+ if not isinstance(native, str):
393
+ raise SessionError('effective developer_instructions is not text')
394
+ return replace_developer(argv, '\n\n'.join(x for x in (native, content, contract) if x))
395
+ boundary = argv.index('--') if '--' in argv else len(argv)
396
+ options, tail = argv[:boundary], argv[boundary:]
397
+ result, index = [], 0
398
+ while index < len(options):
399
+ if options[index] == '--append-system-prompt':
400
+ index += 2
401
+ else:
402
+ result.append(options[index])
403
+ index += 1
404
+ for path in _claude_plugin_paths(snapshot):
405
+ result += ['--plugin-dir', path]
406
+ return [*result, *exclusion, '--append-system-prompt', '\n\n'.join(x for x in (content, contract) if x), *tail]
407
+
408
+
409
+ def session_paths(state_root):
410
+ root = pathlib.Path(state_root)
411
+ for relative in ('', 'runtime', 'runtime/activations', 'sessions', 'sessions/pins',
412
+ 'sessions/pins/codex', 'sessions/pins/claude', 'sessions/snapshots'):
413
+ if (root / relative).is_symlink():
414
+ raise SessionError(f'refusing symlink state directory: {root / relative}')
415
+ return root / 'runtime' / 'activations', root / 'sessions' / 'pins'
416
+
417
+
418
+ def validate_session_id(value):
419
+ if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{3,127}', value):
420
+ raise SessionError('invalid host session id')
421
+ return value
422
+
423
+
424
+ def native_home(host, env, cwd=None):
425
+ key, default = ('CODEX_HOME', '.codex') if host == 'codex' else ('CLAUDE_CONFIG_DIR', '.claude')
426
+ base = pathlib.Path(cwd or pathlib.Path.cwd())
427
+ configured = env.get(key)
428
+ if configured:
429
+ path = pathlib.Path(configured)
430
+ else:
431
+ home = pathlib.Path(env.get('HOME', str(pathlib.Path.home())))
432
+ path = home / default
433
+ if not path.is_absolute():
434
+ path = base / path
435
+ return path.resolve()
436
+
437
+
438
+ def environment_provenance(host, env):
439
+ """Capture only the native-home representation, never credential contents."""
440
+ if host not in ('codex', 'claude'):
441
+ raise SessionError('unknown host')
442
+ key = 'CODEX_HOME' if host == 'codex' else 'CLAUDE_CONFIG_DIR'
443
+ value = env.get(key)
444
+ if key in env and not isinstance(value, str):
445
+ raise SessionError(f'invalid {key} environment value')
446
+ variables = {key: {'state': 'set', 'value': value} if key in env else {'state': 'unset'}}
447
+ # HOME affects a native default when the host variable is absent or empty.
448
+ if value in (None, ''):
449
+ home = env.get('HOME')
450
+ if 'HOME' in env and not isinstance(home, str):
451
+ raise SessionError('invalid HOME environment value')
452
+ variables['HOME'] = {'state': 'set', 'value': home} if 'HOME' in env else {'state': 'unset'}
453
+ return {'schema_version': 1, 'variables': variables}
454
+
455
+
456
+ def restore_environment(record, env):
457
+ """Restore an exact captured set/unset native-home representation."""
458
+ host = record.get('host')
459
+ key = 'CODEX_HOME' if host == 'codex' else 'CLAUDE_CONFIG_DIR' if host == 'claude' else None
460
+ provenance = record.get('environment')
461
+ 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:
462
+ raise SessionError('pin lacks environment provenance; start a new activated session or run an explicit future migration')
463
+ variables = provenance.get('variables')
464
+ if not isinstance(variables, dict) or key not in variables:
465
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
466
+ host_entry = variables[key]
467
+ if not isinstance(host_entry, dict):
468
+ raise SessionError('pin has invalid native-home environment entry')
469
+ needs_home = host_entry.get('state') == 'unset' or host_entry.get('value') == ''
470
+ if set(variables) != ({key, 'HOME'} if needs_home else {key}):
471
+ raise SessionError('pin has incomplete or unknown native-home environment entries')
472
+ restored = dict(env)
473
+ for name in (key, 'HOME'):
474
+ if name not in variables:
475
+ continue
476
+ entry = variables[name]
477
+ if not isinstance(entry, dict) or set(entry) - {'state', 'value'} or entry.get('state') not in {'set', 'unset'}:
478
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
479
+ if entry['state'] == 'unset':
480
+ if 'value' in entry:
481
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
482
+ restored.pop(name, None)
483
+ elif not isinstance(entry.get('value'), str):
484
+ raise SessionError('pin has invalid environment provenance; start a new activated session or run an explicit future migration')
485
+ else:
486
+ restored[name] = entry['value']
487
+ return restored
488
+
489
+
490
+ def prepare(state_root, host, snapshot, argv, cwd=None, env=None, include_global_instructions=True):
491
+ if host not in ('codex', 'claude'):
492
+ raise SessionError('unknown host')
493
+ _global_instruction_choice(host, include_global_instructions)
494
+ intents, _ = session_paths(state_root)
495
+ cwd = pathlib.Path(cwd or pathlib.Path.cwd()).resolve()
496
+ intent_id = uuid.uuid4().hex
497
+ record = {'schema_version': 1, 'intent_id': intent_id, 'state': 'PREPARED',
498
+ 'host': host, 'content_ref': snapshot['content_ref'], 'snapshot_path': str(snapshot['path']),
499
+ 'config_home': str(native_home(host, os.environ if env is None else env, cwd)),
500
+ 'environment': environment_provenance(host, os.environ if env is None else env),
501
+ 'include_global_instructions': include_global_instructions,
502
+ 'argv': list(argv), 'cwd': str(cwd or pathlib.Path.cwd()), 'created': time.time()}
503
+ _record_instruction_choice(record, dict(os.environ if env is None else env))
504
+ atomic_json(intents / intent_id / 'journal.json', record)
505
+ return record
506
+
507
+
508
+ def recorded_cwd(record):
509
+ value = record.get('cwd')
510
+ if not isinstance(value, str) or not pathlib.Path(value).is_absolute():
511
+ raise SessionError('session pin lacks an absolute working directory; start a new activated session')
512
+ try:
513
+ canonical = str(pathlib.Path(value).resolve())
514
+ except (OSError, RuntimeError, ValueError) as exc:
515
+ raise SessionError('session working directory cannot be resolved') from exc
516
+ if canonical != value:
517
+ raise SessionError('session working directory is no longer canonical; start a new activated session')
518
+ return value
519
+
520
+
521
+ def observe_and_pin(state_root, record, host_id, evidence):
522
+ if not isinstance(record.get('intent_id'), str) or not re.fullmatch('[0-9a-f]{32}', record['intent_id']):
523
+ raise SessionError('invalid activation journal identity')
524
+ recorded_cwd(record)
525
+ native_env = restore_environment(record, {})
526
+ _record_instruction_choice(record, native_env, check_paths=False)
527
+ host_id = validate_session_id(host_id)
528
+ intents, pins = session_paths(state_root)
529
+ pin_path = pins / record['host'] / f'{host_id}.json'
530
+ if pin_path.exists():
531
+ existing = read_pin(state_root, record['host'], host_id)
532
+ if existing['intent_id'] != record['intent_id'] or existing['content_ref'] != record['content_ref']:
533
+ raise SessionError('host session already belongs to a different corpus activation')
534
+ record.update(state='HOST_OBSERVED', session_id=host_id, evidence=evidence)
535
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
536
+ atomic_json(pin_path, record)
537
+ record['state'] = 'PINNED'
538
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
539
+ return record
540
+
541
+
542
+ def read_pin(state_root, host, session_id):
543
+ if host not in ('codex', 'claude'):
544
+ raise SessionError('unknown host')
545
+ session_id = validate_session_id(session_id)
546
+ _, pins = session_paths(state_root)
547
+ path = pins / host / f'{session_id}.json'
548
+ if path.is_symlink() or not path.is_file():
549
+ raise SessionError(f'no corpus pin for {host} session {session_id}')
550
+ record = json.loads(path.read_text())
551
+ if record.get('host') != host or record.get('session_id') != session_id:
552
+ raise SessionError('session pin identity mismatch')
553
+ recorded_cwd(record)
554
+ native_env = restore_environment(record, {})
555
+ _record_instruction_choice(record, native_env, check_paths=False)
556
+ snapshot = pathlib.Path(record['snapshot_path'])
557
+ allowed = (pathlib.Path(state_root) / 'sessions' / 'snapshots').resolve()
558
+ if not snapshot.resolve().is_relative_to(allowed) or snapshot.is_symlink():
559
+ raise SessionError('session pin names an unowned snapshot')
560
+ if not snapshot.is_dir():
561
+ raise SessionError('pinned corpus snapshot missing; recover it before resuming')
562
+ from corpus_store import verify_snapshot
563
+ verify_snapshot(snapshot, record['content_ref'])
564
+ return record
565
+
566
+
567
+ def claude_evidence(record, env):
568
+ requested = record.get('requested_session_id')
569
+ if not requested:
570
+ return None
571
+ validate_session_id(requested)
572
+ home = native_home('claude', restore_environment(record, env), recorded_cwd(record))
573
+ from corpus_store import _reject_symlink_path
574
+ _reject_symlink_path(home / 'projects')
575
+ for trace in (home / 'projects').glob(f'*/{requested}.jsonl'):
576
+ _reject_symlink_path(trace.parent)
577
+ _reject_symlink_path(trace)
578
+ with trace.open(encoding='utf-8') as source:
579
+ for line in source:
580
+ try:
581
+ event = json.loads(line)
582
+ except ValueError:
583
+ continue
584
+ if isinstance(event, dict) and event.get('sessionId') == requested:
585
+ if event.get('cwd') and pathlib.Path(event['cwd']).resolve() != pathlib.Path(record['cwd']).resolve():
586
+ continue
587
+ return {'method': 'native-session-log', 'path': str(trace)}
588
+ return None
589
+
590
+
591
+ def recover_activations(state_root, command=None, host=None, env=None):
592
+ """Reconcile exact recorded ids against host evidence; retain unresolved intents."""
593
+ env = dict(os.environ if env is None else env)
594
+ intents, _ = session_paths(state_root)
595
+ result = {'pinned': [], 'pending': []}
596
+ for path in sorted(intents.glob('*/journal.json')):
597
+ if path.is_symlink() or path.parent.is_symlink():
598
+ raise SessionError('symlink activation journal')
599
+ record = json.loads(path.read_text())
600
+ if not isinstance(record, dict) or record.get('intent_id') != path.parent.name or not re.fullmatch('[0-9a-f]{32}', path.parent.name):
601
+ raise SessionError('activation journal identity does not match its owned directory')
602
+ if record.get('state') in ('PREPARED', 'HOST_OBSERVED'):
603
+ try:
604
+ recorded_cwd(record)
605
+ native_env = restore_environment(record, env)
606
+ _record_instruction_choice(record, native_env, check_paths=False)
607
+ except SessionError as exc:
608
+ print(f"agent-bios: activation {record.get('intent_id', path.parent.name)} remains pending: {exc}", file=sys.stderr)
609
+ result['pending'].append(record.get('intent_id', path.parent.name))
610
+ continue
611
+ if record.get('state') == 'HOST_OBSERVED':
612
+ observe_and_pin(state_root, record, record['session_id'], record['evidence'])
613
+ result['pinned'].append(record['session_id'])
614
+ elif record.get('state') == 'PREPARED':
615
+ evidence = None
616
+ requested = record.get('requested_session_id')
617
+ if requested and record['host'] == 'claude':
618
+ try:
619
+ evidence = claude_evidence(record, env)
620
+ except SessionError as exc:
621
+ print(f"agent-bios: activation {record.get('intent_id', '?')} remains pending: {exc}", file=sys.stderr)
622
+ elif requested and record['host'] == 'codex' and host == 'codex' and command:
623
+ validate_session_id(requested)
624
+ try:
625
+ native_env = restore_environment(record, env)
626
+ with CodexServer(command, config_flags(record['argv']), record['cwd'], native_env) as server:
627
+ observed = server.call('thread/read', {'threadId': requested, 'includeTurns': False})
628
+ if observed.get('thread', {}).get('id') == requested:
629
+ evidence = {'method': 'recovered-thread/read'}
630
+ except SessionError as exc:
631
+ print(f"agent-bios: activation {record['intent_id']} remains pending: {exc}", file=sys.stderr)
632
+ if evidence:
633
+ observe_and_pin(state_root, record, requested, evidence)
634
+ result['pinned'].append(requested)
635
+ else:
636
+ result['pending'].append(record['intent_id'])
637
+ return result
638
+
639
+
640
+ def create_codex_session(command, argv, state_root, record, cwd, env):
641
+ with CodexServer(command, config_flags(argv), cwd, env) as server:
642
+ params = {'cwd': str(cwd), 'developerInstructions': instruction_value(argv, 'codex'),
643
+ 'ephemeral': False, 'experimentalRawEvents': False}
644
+ for index, token in enumerate(argv[:-1]):
645
+ if token in ('--model', '-m'):
646
+ params['model'] = argv[index + 1]
647
+ result = server.call('thread/start', params)
648
+ thread = result['thread']
649
+ host_id = validate_session_id(thread['id'])
650
+ # thread/start alone may return an id before persisting a resumable thread.
651
+ # Retain the exact id in PREPARED before recording a small developer item;
652
+ # inject_items persists history without generating a model turn.
653
+ record['requested_session_id'] = host_id
654
+ intents, _ = session_paths(state_root)
655
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
656
+ server.call('thread/inject_items', {'threadId': host_id, 'items': [
657
+ {'type': 'message', 'role': 'developer', 'content': [
658
+ {'type': 'input_text', 'text': f"agent-bios session snapshot: {record['content_ref']}"}]}]})
659
+ with CodexServer(command, config_flags(argv), cwd, env) as server:
660
+ persisted = server.call('thread/read', {'threadId': host_id, 'includeTurns': False})
661
+ if persisted.get('thread', {}).get('id') != host_id:
662
+ raise SessionError('host did not persist the requested corpus session')
663
+ observe_and_pin(state_root, record, host_id,
664
+ {'method': 'thread/start+inject_items+read', 'thread_path': thread.get('path')})
665
+ return host_id
666
+
667
+
668
+ def launch(command, argv, state_root, host, snapshot, cwd=None, env=None, resume_id=None,
669
+ include_global_instructions=True):
670
+ """Start a pinned native session, returning its exit status."""
671
+ cwd = pathlib.Path(cwd or pathlib.Path.cwd()).resolve()
672
+ env = dict(os.environ if env is None else env)
673
+ if resume_id and include_global_instructions is not True:
674
+ raise SessionError('resume uses its recorded global instruction choice; omit the override or start a new session')
675
+ if not resume_id:
676
+ _global_instruction_choice(host, include_global_instructions)
677
+ recovered = recover_activations(state_root, command, host, env)
678
+ if recovered['pending']:
679
+ print(f"agent-bios: {len(recovered['pending'])} activation(s) lack host evidence; their snapshots remain retained.", file=sys.stderr)
680
+ if resume_id:
681
+ record = read_pin(state_root, host, resume_id)
682
+ env = restore_environment(record, env)
683
+ if not _record_instruction_choice(record, env):
684
+ _claude_exclusion_version(command, record['cwd'], env)
685
+ if host == 'claude':
686
+ pinned = _verified_launch_snapshot(state_root, {'path': record['snapshot_path'], 'content_ref': record['content_ref']})
687
+ validate_claude_plugins(command, pinned, record['cwd'], env)
688
+ argv = record['argv']
689
+ native = ['resume', resume_id, *argv] if host == 'codex' else ['--resume', resume_id, *argv]
690
+ return subprocess.call([command, *native], cwd=record['cwd'], env=env)
691
+ snapshot = _verified_launch_snapshot(state_root, snapshot)
692
+ argv = compose_argv(command, argv, host, snapshot, cwd, env, include_global_instructions)
693
+ if host == 'claude':
694
+ validate_claude_plugins(command, snapshot, cwd, env)
695
+ record = prepare(state_root, host, snapshot, argv, cwd, env, include_global_instructions)
696
+ if host == 'codex':
697
+ # Create the durable thread before opening the native resume UI. The returned
698
+ # id comes from the real host and is pinned before any user turn is possible.
699
+ create_codex_session(command, argv, state_root, record, cwd, env)
700
+ native = ['resume', record['session_id'], *argv]
701
+ return subprocess.call([command, *native], cwd=cwd, env=env)
702
+ requested_id = str(uuid.uuid4())
703
+ record['requested_session_id'] = requested_id
704
+ intents, _ = session_paths(state_root)
705
+ atomic_json(intents / record['intent_id'] / 'journal.json', record)
706
+ native = ['--session-id', requested_id, *argv]
707
+ child = subprocess.Popen([command, *native], cwd=cwd, env=env)
708
+ try:
709
+ while True:
710
+ if record['state'] == 'PREPARED':
711
+ evidence = claude_evidence(record, env)
712
+ if evidence:
713
+ observe_and_pin(state_root, record, requested_id, evidence)
714
+ if child.poll() is not None:
715
+ if record['state'] != 'PINNED':
716
+ print(f"agent-bios: no host session evidence; activation {record['intent_id']} retained for recovery.", file=sys.stderr)
717
+ return child.returncode
718
+ time.sleep(0.15)
719
+ finally:
720
+ if child.poll() is None:
721
+ child.terminate()
722
+ try:
723
+ child.wait(timeout=5)
724
+ except subprocess.TimeoutExpired:
725
+ child.kill()
726
+ child.wait(timeout=5)