agent-bios 0.19.1 → 0.19.3

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