agent-bios 0.19.2 → 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.
- package/DEPENDENCIES.md +32 -4
- package/README.md +54 -4
- package/claude/guides/cli-multi-model-workflow.md +1 -1
- package/claude/guides/coding-staged-workflow.md +17 -0
- package/claude/guides/llm-capability-boundary.md +7 -1
- package/claude/guides/tooling-gotchas.md +20 -1
- package/claude/guides/ui-design/visual-direction.md +88 -0
- package/claude/guides/ui-design.md +90 -0
- package/claude/guides/verification-discipline.md +10 -1
- package/claude/hooks/tooling-gotchas-hook.py +41 -0
- package/codex/guides/cli-multi-model-workflow.md +1 -1
- package/codex/guides/coding-staged-workflow.md +17 -0
- package/codex/guides/llm-capability-boundary.md +7 -1
- package/codex/guides/tooling-gotchas.md +20 -1
- package/codex/guides/ui-design/visual-direction.md +88 -0
- package/codex/guides/ui-design.md +90 -0
- package/codex/guides/verification-discipline.md +10 -1
- package/compose/app_bridge/scripts/bridge.py +23 -6
- package/compose/app_desktop/server.py +250 -0
- package/compose/domains.json +1 -0
- package/compose/host_platform.py +121 -0
- package/compose/instructions-state.py +5 -2
- package/compose/instructions_app.py +281 -48
- package/compose/instructions_catalog.py +2 -2
- package/compose/instructions_import.py +19 -11
- package/compose/instructions_install.py +77 -11
- package/compose/instructions_session.py +6 -6
- package/compose/instructions_setup.py +41 -4
- package/compose/instructions_setup_cli.py +31 -10
- package/compose/instructions_setup_i18n.py +3 -0
- package/compose/instructions_store.py +6 -6
- package/compose/instructions_transaction.py +8 -6
- package/compose/instructions_ui_runtime.py +2 -1
- package/compose/native_cli.py +52 -0
- package/compose/runtime_entry.py +58 -0
- package/compose/windows_deploy.py +719 -0
- package/docs/instructions.md +1 -0
- package/docs/releases/0.19.3.md +107 -0
- package/docs/session-model.md +8 -0
- package/docs/setup.md +36 -0
- package/docs/windows.md +99 -0
- package/install.sh +1 -0
- package/launch/agent-launch.py +12 -4
- package/launch/agent-launch.zsh +11 -1
- package/package.json +8 -3
- package/provenance.json +1 -1
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Explicit
|
|
2
|
+
"""Explicit app discovery and per-task instructions context delivery.
|
|
3
|
+
|
|
4
|
+
The Codex app pulls through a registered skill; Claude Desktop pulls through a
|
|
5
|
+
generated `.mcpb` bundle whose server calls the session operations here.
|
|
6
|
+
"""
|
|
3
7
|
from __future__ import annotations
|
|
8
|
+
try:
|
|
9
|
+
from host_platform import cli_argv, create_junction, python_argv, runtime_environment
|
|
10
|
+
except ImportError:
|
|
11
|
+
from .host_platform import cli_argv, create_junction, python_argv, runtime_environment
|
|
4
12
|
|
|
5
13
|
import argparse
|
|
6
14
|
from datetime import datetime, timezone
|
|
7
15
|
import hashlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import io
|
|
8
18
|
import json
|
|
9
19
|
import os
|
|
10
20
|
from pathlib import Path
|
|
11
21
|
import re
|
|
22
|
+
import secrets
|
|
12
23
|
import shutil
|
|
13
24
|
import sys
|
|
14
25
|
import tempfile
|
|
15
26
|
from typing import Any
|
|
27
|
+
import zipfile
|
|
16
28
|
|
|
17
29
|
try:
|
|
18
30
|
from instructions_store import InstructionsStore, InstructionsStoreError
|
|
@@ -26,10 +38,23 @@ except ImportError:
|
|
|
26
38
|
|
|
27
39
|
SCHEMA_VERSION = 1
|
|
28
40
|
BRIDGE_MEMBERS = ("SKILL.md", "agents/openai.yaml", "scripts/bridge.py",
|
|
29
|
-
"scripts/instructions_transaction.py", "bridge.json")
|
|
41
|
+
"scripts/instructions_transaction.py", "scripts/host_platform.py", "bridge.json")
|
|
42
|
+
PREVIOUS_BRIDGE_MEMBERS = tuple(x for x in BRIDGE_MEMBERS if x != "scripts/host_platform.py")
|
|
30
43
|
LEGACY_BRIDGE_MEMBERS = ("SKILL.md", "agents/openai.yaml", "scripts/bridge.py",
|
|
31
44
|
"scripts/corpus_transaction.py", "bridge.json")
|
|
32
45
|
SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
|
|
46
|
+
# Desktop sends no conversation identity with a tool call (2.9939.4 and 2.16120.0,
|
|
47
|
+
# design/app-session-reach/2026-09-30T0645--20084c8--desktop-mcpb-qualification.md),
|
|
48
|
+
# so AppSessions mints one; the prefix keeps it apart from Codex task ids.
|
|
49
|
+
DESKTOP_SESSION_ID = re.compile(r"desktop-[a-f0-9]{32}\Z")
|
|
50
|
+
END_MARKER = re.compile(r"agent-bios end [a-f0-9]{12}\Z")
|
|
51
|
+
# Receipt host -> the snapshot host whose items it receives.
|
|
52
|
+
APP_HOSTS = {"codex": "codex", "claude-desktop": "claude"}
|
|
53
|
+
# The smallest inline boundary Desktop has shown (2.9939.4). 2.16120.0 inlined
|
|
54
|
+
# more, so crossing it is disclosed, never refused.
|
|
55
|
+
DESKTOP_INLINE_FLOOR = 49_152
|
|
56
|
+
DESKTOP_BUNDLE_MEMBERS = ("manifest.json", "server/desktop.json", "server/host_platform.py",
|
|
57
|
+
"server/instructions_transaction.py", "server/server.py")
|
|
33
58
|
|
|
34
59
|
|
|
35
60
|
class AppError(RuntimeError):
|
|
@@ -106,6 +131,11 @@ class AppBridge:
|
|
|
106
131
|
saved = _read_json(current / "bridge.json")
|
|
107
132
|
if "launch_venv" in saved:
|
|
108
133
|
config["launch_venv"] = saved["launch_venv"]
|
|
134
|
+
if "python_binding" in saved:
|
|
135
|
+
config["python_binding"] = saved["python_binding"]
|
|
136
|
+
binding = runtime_environment(self.env)
|
|
137
|
+
if binding:
|
|
138
|
+
config["python_binding"] = binding
|
|
109
139
|
return config
|
|
110
140
|
|
|
111
141
|
def _source_members(self) -> dict[str, bytes]:
|
|
@@ -121,23 +151,36 @@ class AppBridge:
|
|
|
121
151
|
members[name] = _json_bytes(self._config())
|
|
122
152
|
continue
|
|
123
153
|
path = release / "compose" / Path(name).name if name in {
|
|
124
|
-
"scripts/instructions_transaction.py", "scripts/corpus_transaction.py"
|
|
154
|
+
"scripts/instructions_transaction.py", "scripts/corpus_transaction.py", "scripts/host_platform.py"
|
|
125
155
|
} else source / name
|
|
126
156
|
reject_symlink_ancestors(path)
|
|
127
157
|
if not path.is_file():
|
|
128
158
|
raise AppError(f"installed release has no app bridge member: {path}")
|
|
129
159
|
members[name] = path.read_bytes()
|
|
160
|
+
if os.name == "nt" and name == "SKILL.md":
|
|
161
|
+
interpreter = sys.executable.replace("'", "''")
|
|
162
|
+
command = python_argv(Path('$BRIDGE'))
|
|
163
|
+
prefix = '& ' + ' '.join(('"$BRIDGE"' if word == '$BRIDGE' else "'" + word.replace("'", "''") + "'") for word in command)
|
|
164
|
+
text = members[name].decode("utf-8").replace('python3 "$BRIDGE"', prefix)
|
|
165
|
+
text += "\nOn Windows use PowerShell and the bundled interpreter shown above. Set $BRIDGE to the absolute scripts/bridge.py path beside this skill. Follow returned command argument arrays for setup; do not translate them into Bash commands.\n"
|
|
166
|
+
members[name] = text.encode("utf-8")
|
|
130
167
|
return members
|
|
131
168
|
|
|
132
169
|
def _owned_target(self) -> Path | None:
|
|
133
170
|
reject_symlink_ancestors(self.target.parent)
|
|
134
171
|
if not os.path.lexists(self.target):
|
|
135
172
|
return None
|
|
136
|
-
if not self.target.is_symlink():
|
|
173
|
+
if not (self.target.is_symlink() or (os.name == "nt" and self.target.is_junction())):
|
|
137
174
|
raise AppError(f"preserving unowned app skill: {self.target}")
|
|
138
|
-
raw = Path(os.readlink(self.target))
|
|
139
|
-
|
|
175
|
+
raw = self.target.resolve() if os.name == "nt" else Path(os.readlink(self.target))
|
|
176
|
+
reject_symlink_ancestors(self.generations)
|
|
177
|
+
same_parent = raw.parent.resolve() == self.generations.resolve() if os.name == "nt" else raw.parent == self.generations
|
|
178
|
+
if not raw.is_absolute() or not same_parent or not re.fullmatch(r"[a-f0-9]{64}", raw.name):
|
|
140
179
|
raise AppError(f"preserving unowned app skill link: {self.target}")
|
|
180
|
+
if os.name == "nt":
|
|
181
|
+
# Keep the saved root spelling: canonicalizing the record itself would
|
|
182
|
+
# invalidate its exact ownership/context checks. Only compare locations.
|
|
183
|
+
raw = self.generations / raw.name
|
|
141
184
|
reject_symlink_ancestors(raw)
|
|
142
185
|
if not raw.is_dir():
|
|
143
186
|
raise AppError(f"owned app skill generation is unavailable: {raw}")
|
|
@@ -145,12 +188,12 @@ class AppBridge:
|
|
|
145
188
|
if any(path.is_symlink() for path in paths):
|
|
146
189
|
raise AppError(f"preserving redirected app skill generation: {raw}")
|
|
147
190
|
files = {path.relative_to(raw).as_posix(): path.read_bytes() for path in paths if path.is_file()}
|
|
148
|
-
if set(files) not in (set(BRIDGE_MEMBERS), set(LEGACY_BRIDGE_MEMBERS)) or _tree_digest(files) != raw.name:
|
|
191
|
+
if set(files) not in (set(BRIDGE_MEMBERS), set(PREVIOUS_BRIDGE_MEMBERS), set(LEGACY_BRIDGE_MEMBERS)) or _tree_digest(files) != raw.name:
|
|
149
192
|
raise AppError(f"preserving changed app skill generation: {raw}")
|
|
150
193
|
config = _read_json(raw / "bridge.json")
|
|
151
194
|
base = self._base_config()
|
|
152
195
|
if (any(config.get(key) != value for key, value in base.items())
|
|
153
|
-
or set(config) - set(base) - {"launch_venv"}
|
|
196
|
+
or set(config) - set(base) - {"launch_venv", "python_binding"}
|
|
154
197
|
or ("launch_venv" in config and (not isinstance(config["launch_venv"], str)
|
|
155
198
|
or (config["launch_venv"] and not Path(config["launch_venv"]).is_absolute())))):
|
|
156
199
|
raise AppError(f"app skill belongs to different private root settings: {self.target}")
|
|
@@ -170,10 +213,12 @@ class AppBridge:
|
|
|
170
213
|
"""Identify our link namespace without opening unrelated skill contents."""
|
|
171
214
|
try:
|
|
172
215
|
reject_symlink_ancestors(self.target.parent)
|
|
173
|
-
if not self.target.is_symlink():
|
|
216
|
+
if not (self.target.is_symlink() or (os.name == "nt" and self.target.is_junction())):
|
|
174
217
|
return False
|
|
175
|
-
target = Path(os.readlink(self.target))
|
|
176
|
-
|
|
218
|
+
target = self.target.resolve() if os.name == "nt" else Path(os.readlink(self.target))
|
|
219
|
+
reject_symlink_ancestors(self.generations)
|
|
220
|
+
same_parent = target.parent.resolve() == self.generations.resolve() if os.name == "nt" else target.parent == self.generations
|
|
221
|
+
return target.is_absolute() and same_parent
|
|
177
222
|
except (OSError, TransactionError):
|
|
178
223
|
return False
|
|
179
224
|
|
|
@@ -228,11 +273,16 @@ class AppBridge:
|
|
|
228
273
|
os.close(descriptor)
|
|
229
274
|
os.unlink(temporary)
|
|
230
275
|
try:
|
|
231
|
-
os.
|
|
276
|
+
if os.name == "nt":
|
|
277
|
+
create_junction(Path(temporary), generation)
|
|
278
|
+
if before is not None:
|
|
279
|
+
self.target.rmdir()
|
|
280
|
+
else:
|
|
281
|
+
os.symlink(str(generation), temporary)
|
|
232
282
|
os.replace(temporary, self.target)
|
|
233
283
|
finally:
|
|
234
284
|
if os.path.lexists(temporary):
|
|
235
|
-
os.unlink(temporary)
|
|
285
|
+
os.rmdir(temporary) if os.name == "nt" and Path(temporary).is_junction() else os.unlink(temporary)
|
|
236
286
|
return result
|
|
237
287
|
|
|
238
288
|
def unregister(self, dry_run: bool = False) -> dict[str, Any]:
|
|
@@ -244,7 +294,7 @@ class AppBridge:
|
|
|
244
294
|
with transaction_lock(self.state_root):
|
|
245
295
|
before = self._owned_target()
|
|
246
296
|
if before is not None:
|
|
247
|
-
self.target.unlink()
|
|
297
|
+
self.target.rmdir() if os.name == "nt" else self.target.unlink()
|
|
248
298
|
return {"registered": False, "dry_run": False, "changed": before is not None,
|
|
249
299
|
"discovery_path": str(self.target), "retained_private_generations": True}
|
|
250
300
|
|
|
@@ -262,16 +312,37 @@ class AppBridge:
|
|
|
262
312
|
class AppSessions:
|
|
263
313
|
"""Record returned context, separately from native launch pins and host proof."""
|
|
264
314
|
|
|
265
|
-
def __init__(self, repo: Path, environ: dict[str, str] | None = None):
|
|
315
|
+
def __init__(self, repo: Path, environ: dict[str, str] | None = None, host: str = "codex"):
|
|
316
|
+
if host not in APP_HOSTS:
|
|
317
|
+
raise AppError(f"unsupported app host: {host}")
|
|
266
318
|
self.repo = Path(repo).absolute()
|
|
319
|
+
self.host = host
|
|
267
320
|
self.env, self.home, self.state_root, self.user_root = _roots(environ)
|
|
268
321
|
|
|
269
|
-
|
|
322
|
+
@property
|
|
323
|
+
def desktop(self) -> bool:
|
|
324
|
+
return self.host == "claude-desktop"
|
|
325
|
+
|
|
326
|
+
def _session(self, session: str | None, *, mint: bool = False) -> str:
|
|
327
|
+
if self.desktop:
|
|
328
|
+
if session is None and mint:
|
|
329
|
+
return "desktop-" + secrets.token_hex(16)
|
|
330
|
+
if not isinstance(session, str) or not DESKTOP_SESSION_ID.fullmatch(session):
|
|
331
|
+
raise AppError("Desktop session requires the session_id that preview or use returned in this conversation")
|
|
332
|
+
return session
|
|
270
333
|
value = session if session is not None else self.env.get("CODEX_THREAD_ID")
|
|
271
334
|
if not isinstance(value, str) or not SESSION_ID.fullmatch(value):
|
|
272
335
|
raise AppError("app session requires CODEX_THREAD_ID or --session with a valid task id")
|
|
273
336
|
return value
|
|
274
337
|
|
|
338
|
+
def _cwd(self, cwd: Path | None) -> Path:
|
|
339
|
+
if not self.desktop:
|
|
340
|
+
return Path(cwd or Path.cwd()).absolute()
|
|
341
|
+
if cwd is not None:
|
|
342
|
+
raise AppError("Desktop delivery has no project scope; it does not take a working directory")
|
|
343
|
+
# Desktop names no project: the filesystem root lies inside no imported project scope.
|
|
344
|
+
return Path(os.path.abspath(os.sep))
|
|
345
|
+
|
|
275
346
|
def _path(self, session: str) -> Path:
|
|
276
347
|
return self.state_root / "sessions/app-context" / f"{session}.json"
|
|
277
348
|
|
|
@@ -279,31 +350,50 @@ class AppSessions:
|
|
|
279
350
|
path = self._path(session)
|
|
280
351
|
reject_symlink_ancestors(path)
|
|
281
352
|
if not path.exists():
|
|
282
|
-
return {"schema_version": SCHEMA_VERSION, "host":
|
|
353
|
+
return {"schema_version": SCHEMA_VERSION, "host": self.host, "session_id": session,
|
|
283
354
|
"enabled": False, "deliveries": []}
|
|
284
355
|
record = _read_json(path)
|
|
285
|
-
if (record.get("schema_version") != SCHEMA_VERSION or record.get("host") !=
|
|
356
|
+
if (record.get("schema_version") != SCHEMA_VERSION or record.get("host") != self.host
|
|
286
357
|
or record.get("session_id") != session or not isinstance(record.get("enabled"), bool)
|
|
287
358
|
or not isinstance(record.get("deliveries"), list)):
|
|
288
359
|
raise AppError(f"invalid app session receipt: {path}")
|
|
289
360
|
for delivery in record["deliveries"]:
|
|
290
361
|
if (not isinstance(delivery, dict) or delivery.get("delivery") != "returned-as-context"
|
|
291
362
|
or not isinstance(delivery.get("content_ref"), str)
|
|
292
|
-
or not re.fullmatch(r"[a-f0-9]{64}", delivery["content_ref"])
|
|
363
|
+
or not re.fullmatch(r"[a-f0-9]{64}", delivery["content_ref"])
|
|
364
|
+
or ("end_marker" in delivery and not (isinstance(delivery["end_marker"], str)
|
|
365
|
+
and END_MARKER.fullmatch(delivery["end_marker"])))
|
|
366
|
+
or ("confirmed_at" in delivery and not isinstance(delivery["confirmed_at"], str))):
|
|
293
367
|
raise AppError(f"invalid app session delivery: {path}")
|
|
294
368
|
return record
|
|
295
369
|
|
|
296
370
|
@staticmethod
|
|
297
371
|
def _view(record: dict[str, Any]) -> dict[str, Any]:
|
|
298
372
|
delivered = bool(record["deliveries"])
|
|
299
|
-
|
|
373
|
+
view = {**record, "ever_delivered": delivered,
|
|
300
374
|
"active_content_ref": record["deliveries"][-1]["content_ref"] if delivered and record["enabled"] else None,
|
|
301
375
|
"native_activation": False, "host_loading": "unverified",
|
|
302
376
|
"context_retracted": False,
|
|
303
377
|
"clean_exclusion_requires_new_session": delivered and not record["enabled"]}
|
|
378
|
+
if record["host"] == "claude-desktop":
|
|
379
|
+
# A marker reported back proves the end was read, never inline delivery.
|
|
380
|
+
view["latest_end_confirmed"] = bool(delivered and record["deliveries"][-1].get("confirmed_at"))
|
|
381
|
+
view["project_scope"] = "none"
|
|
382
|
+
return view
|
|
304
383
|
|
|
305
|
-
def status(self, session: str | None = None) -> dict[str, Any]:
|
|
306
|
-
|
|
384
|
+
def status(self, session: str | None = None, *, end_marker_seen: str | None = None) -> dict[str, Any]:
|
|
385
|
+
session = self._session(session)
|
|
386
|
+
if end_marker_seen is None:
|
|
387
|
+
return self._view(self._read(session))
|
|
388
|
+
reject_symlink_ancestors(self.state_root)
|
|
389
|
+
with transaction_lock(self.state_root):
|
|
390
|
+
record = self._read(session)
|
|
391
|
+
latest = record["deliveries"][-1] if record["deliveries"] else None
|
|
392
|
+
if latest is None or latest.get("end_marker") != end_marker_seen.strip():
|
|
393
|
+
raise AppError("that end marker is not the latest delivery's in this session; read the delivered text to its last line")
|
|
394
|
+
latest.setdefault("confirmed_at", datetime.now(timezone.utc).isoformat())
|
|
395
|
+
_write_json(self._path(session), record)
|
|
396
|
+
return {**self._view(record), "message": "The end of the latest delivery was read. This does not show whether the host kept it inline or saved it to a file."}
|
|
307
397
|
|
|
308
398
|
def off(self, session: str | None = None) -> dict[str, Any]:
|
|
309
399
|
session = self._session(session)
|
|
@@ -328,46 +418,55 @@ class AppSessions:
|
|
|
328
418
|
store = InstructionsStore(release, self.state_root, self.user_root)
|
|
329
419
|
if selection is not None and selection_mode is None:
|
|
330
420
|
selection_mode = "selected"
|
|
331
|
-
return store.snapshot(host=
|
|
332
|
-
cwd=
|
|
421
|
+
return store.snapshot(host=APP_HOSTS[self.host], selection=selection, selection_mode=selection_mode,
|
|
422
|
+
cwd=self._cwd(cwd), dry_run=dry_run, native=False)
|
|
333
423
|
|
|
334
424
|
def _runtime_commands(self) -> dict[str, Any]:
|
|
335
425
|
release = confirmed_release(self.state_root)
|
|
336
426
|
bridge = AppBridge(self.repo, self.env)
|
|
337
427
|
result = {"package_root": str(release),
|
|
338
|
-
"learn_argv":
|
|
428
|
+
"learn_argv": cli_argv(release, "learn"),
|
|
339
429
|
"environment": {"AGENT_BIOS_PACKAGE_ROOT": str(release),
|
|
340
430
|
"AGENT_BIOS_STATE_DIR": str(self.state_root),
|
|
341
431
|
"AGENT_BIOS_INSTRUCTIONS_DIR": str(self.user_root),
|
|
342
432
|
"AGENT_BIOS_CORPUS_DIR": str(self.user_root),
|
|
343
433
|
"AGENT_BIOS_PRIVATE_CORPUS": "1",
|
|
344
434
|
"AGENT_BIOS_PRIVATE_INSTRUCTIONS": "1", "AGENT_BIOS_LEGACY_INSTALL": "0"}}
|
|
435
|
+
result["environment"].update(runtime_environment())
|
|
345
436
|
registered = bridge.managed_status().get("registered")
|
|
346
437
|
if "AGENT_LAUNCH_VENV" in self.env or registered:
|
|
347
438
|
config = bridge._config()
|
|
348
439
|
if "launch_venv" in config:
|
|
349
440
|
result["environment"]["AGENT_LAUNCH_VENV"] = config["launch_venv"]
|
|
350
441
|
if registered:
|
|
351
|
-
result["bridge_learn_argv"] =
|
|
442
|
+
result["bridge_learn_argv"] = python_argv(bridge.target / "scripts/bridge.py", "learn")
|
|
352
443
|
return result
|
|
353
444
|
|
|
445
|
+
def _desktop_size(self, text: str) -> dict[str, Any]:
|
|
446
|
+
size = len(text.encode("utf-8"))
|
|
447
|
+
return {"instruction_bytes": size, "project_scope": "none",
|
|
448
|
+
"host_may_save_to_file": size > DESKTOP_INLINE_FLOOR}
|
|
449
|
+
|
|
354
450
|
def preview(self, session: str | None = None, *, selection: list[str] | None = None,
|
|
355
451
|
selection_mode: str | None = None, cwd: Path | None = None) -> dict[str, Any]:
|
|
356
|
-
session = self._session(session)
|
|
452
|
+
session = self._session(session, mint=True)
|
|
357
453
|
if selection_mode == "none":
|
|
358
454
|
return {"session_id": session, "selection_mode": "none", "content_ref": None,
|
|
359
455
|
"instruction_characters": 0, "delivery": "preview-only", "native_activation": False}
|
|
360
456
|
snapshot = self._snapshot(selection=selection, selection_mode=selection_mode, cwd=cwd, dry_run=True)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
457
|
+
result = {"session_id": session, "content_ref": snapshot["content_ref"], "revision": snapshot["revision"],
|
|
458
|
+
"instruction_characters": len(snapshot["instruction_text"]),
|
|
459
|
+
"unavailable": snapshot.get("unavailable", []), "selection": selection,
|
|
460
|
+
"selection_mode": snapshot.get("selection_mode", selection_mode),
|
|
461
|
+
"delivery": "preview-only", "native_activation": False}
|
|
462
|
+
if self.desktop:
|
|
463
|
+
result.update(self._desktop_size(snapshot["instruction_text"]))
|
|
464
|
+
return result
|
|
366
465
|
|
|
367
466
|
def use(self, session: str | None = None, *, selection: list[str] | None = None,
|
|
368
467
|
selection_mode: str | None = None, cwd: Path | None = None,
|
|
369
468
|
expected_content_ref: str | None = None) -> dict[str, Any]:
|
|
370
|
-
session = self._session(session)
|
|
469
|
+
session = self._session(session, mint=True)
|
|
371
470
|
if selection_mode == "none":
|
|
372
471
|
return self.off(session)
|
|
373
472
|
reject_symlink_ancestors(self.state_root)
|
|
@@ -378,19 +477,144 @@ class AppSessions:
|
|
|
378
477
|
raise AppError("instructions preview changed; preview again before using this session selection")
|
|
379
478
|
if not snapshot["instruction_text"].strip():
|
|
380
479
|
return self.off(session)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
record["
|
|
386
|
-
|
|
387
|
-
runtime = self._runtime_commands()
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
480
|
+
latest = record["deliveries"][-1] if record["deliveries"] else None
|
|
481
|
+
# Desktop retries a failing call without telling the model, and a retry exists
|
|
482
|
+
# exactly when the first result may not have arrived: return the same text and
|
|
483
|
+
# receipt again rather than recording a second delivery.
|
|
484
|
+
repeat = (self.desktop and record["enabled"] and latest is not None
|
|
485
|
+
and latest["content_ref"] == snapshot["content_ref"])
|
|
486
|
+
runtime = None if self.desktop else self._runtime_commands()
|
|
487
|
+
if not repeat:
|
|
488
|
+
delivery = {"at": datetime.now(timezone.utc).isoformat(), "content_ref": snapshot["content_ref"],
|
|
489
|
+
"snapshot_path": snapshot["path"], "revision": snapshot["revision"],
|
|
490
|
+
"cwd": str(self._cwd(cwd)), "delivery": "returned-as-context",
|
|
491
|
+
"instruction_sha256": hashlib.sha256(snapshot["instruction_text"].encode("utf-8")).hexdigest()}
|
|
492
|
+
if self.desktop:
|
|
493
|
+
delivery["end_marker"] = "agent-bios end " + secrets.token_hex(6)
|
|
494
|
+
record["deliveries"].append(delivery)
|
|
495
|
+
record["enabled"] = True
|
|
496
|
+
_write_json(self._path(session), record)
|
|
497
|
+
latest = delivery
|
|
498
|
+
result = {**self._view(record), "delivery": "returned-as-context",
|
|
499
|
+
"content_ref": snapshot["content_ref"], "instruction_text": snapshot["instruction_text"],
|
|
500
|
+
"unavailable": snapshot.get("unavailable", []),
|
|
501
|
+
"message": "Returned the selected immutable instructions as task context. This receipt is not proof of model reading or native startup activation."}
|
|
502
|
+
if self.desktop:
|
|
503
|
+
result.update(self._desktop_size(snapshot["instruction_text"]))
|
|
504
|
+
result.update({"repeat": repeat, "end_marker": latest["end_marker"]})
|
|
505
|
+
else:
|
|
506
|
+
result["runtime"] = runtime
|
|
507
|
+
return result
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _zip_bytes(members: dict[str, bytes]) -> bytes:
|
|
511
|
+
"""Byte-stable archive: fixed order, times and modes, so equal members give equal files."""
|
|
512
|
+
buffer = io.BytesIO()
|
|
513
|
+
with zipfile.ZipFile(buffer, "w") as archive:
|
|
514
|
+
for name in sorted(members):
|
|
515
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
516
|
+
info.external_attr = 0o644 << 16
|
|
517
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
518
|
+
archive.writestr(info, members[name])
|
|
519
|
+
return buffer.getvalue()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class DesktopBundle:
|
|
523
|
+
"""Write one content-addressed Claude Desktop bundle; installing it is the user's step.
|
|
524
|
+
|
|
525
|
+
Desktop resolves a bare `python3` through the user's login-shell PATH, which on
|
|
526
|
+
stock macOS reaches 3.9, so the manifest names the interpreter running agent-bios.
|
|
527
|
+
Nothing here writes into Desktop's own directories.
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
def __init__(self, repo: Path, environ: dict[str, str] | None = None):
|
|
531
|
+
self.repo = Path(repo).absolute()
|
|
532
|
+
self.env, self.home, self.state_root, self.user_root = _roots(environ)
|
|
533
|
+
self.bundles = self.state_root / "runtime/desktop-bundles"
|
|
534
|
+
|
|
535
|
+
def _interpreter(self, binding: dict[str, str]) -> str:
|
|
536
|
+
if binding:
|
|
537
|
+
return binding["AGENT_BIOS_PYTHON_EXECUTABLE"]
|
|
538
|
+
if sys.version_info < (3, 11):
|
|
539
|
+
raise AppError(f"the Desktop bundle needs Python 3.11 or newer; this is {sys.version.split()[0]} at {sys.executable}")
|
|
540
|
+
if not sys.executable or not Path(sys.executable).is_absolute():
|
|
541
|
+
raise AppError("cannot name an absolute Python interpreter for the Desktop bundle")
|
|
542
|
+
return sys.executable
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _tools(server: Path) -> list[dict[str, str]]:
|
|
546
|
+
spec = importlib.util.spec_from_file_location("agent_bios_desktop_server", server)
|
|
547
|
+
if spec is None or spec.loader is None:
|
|
548
|
+
raise AppError(f"cannot read the Desktop server tools: {server}")
|
|
549
|
+
module = importlib.util.module_from_spec(spec)
|
|
550
|
+
spec.loader.exec_module(module)
|
|
551
|
+
return [{"name": tool["name"], "description": tool["description"]} for tool in module.TOOLS]
|
|
552
|
+
|
|
553
|
+
def _members(self) -> tuple[dict[str, bytes], str]:
|
|
554
|
+
reject_symlink_ancestors(self.state_root)
|
|
555
|
+
guard_pending(self.state_root)
|
|
556
|
+
release = confirmed_release(self.state_root)
|
|
557
|
+
binding = runtime_environment(self.env)
|
|
558
|
+
interpreter = self._interpreter(binding)
|
|
559
|
+
sources = {"server/server.py": release / "compose/app_desktop/server.py",
|
|
560
|
+
"server/instructions_transaction.py": release / "compose/instructions_transaction.py",
|
|
561
|
+
"server/host_platform.py": release / "compose/host_platform.py"}
|
|
562
|
+
members: dict[str, bytes] = {}
|
|
563
|
+
for name, source in sources.items():
|
|
564
|
+
reject_symlink_ancestors(source)
|
|
565
|
+
if not source.is_file():
|
|
566
|
+
raise AppError(f"installed release has no Desktop bundle member: {source}")
|
|
567
|
+
members[name] = source.read_bytes()
|
|
568
|
+
config: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "home": str(self.home),
|
|
569
|
+
"state_root": str(self.state_root), "user_root": str(self.user_root)}
|
|
570
|
+
if binding:
|
|
571
|
+
config["python_binding"] = binding
|
|
572
|
+
members["server/desktop.json"] = _json_bytes(config)
|
|
573
|
+
version = _read_json(release / "package.json").get("version")
|
|
574
|
+
if not isinstance(version, str):
|
|
575
|
+
raise AppError("installed release has no package version")
|
|
576
|
+
manifest = {
|
|
577
|
+
"manifest_version": "0.3", "name": "agent-bios", "display_name": "agent-bios", "version": version,
|
|
578
|
+
"description": "Pull your selected agent-bios work environment into a conversation when you ask for it.",
|
|
579
|
+
"author": {"name": "agent-bios"},
|
|
580
|
+
"server": {"type": "python", "entry_point": "server/server.py",
|
|
581
|
+
"mcp_config": {"command": interpreter, "args": ["-I", "${__dirname}/server/server.py"]}},
|
|
582
|
+
"tools": self._tools(release / "compose/app_desktop/server.py"),
|
|
583
|
+
"compatibility": {"platforms": ["darwin"]},
|
|
584
|
+
}
|
|
585
|
+
members["manifest.json"] = _json_bytes(manifest)
|
|
586
|
+
if set(members) != set(DESKTOP_BUNDLE_MEMBERS):
|
|
587
|
+
raise AppError("Desktop bundle member inventory changed")
|
|
588
|
+
return members, interpreter
|
|
589
|
+
|
|
590
|
+
def build(self, dry_run: bool = False) -> dict[str, Any]:
|
|
591
|
+
members, interpreter = self._members()
|
|
592
|
+
path = self.bundles / _tree_digest(members) / "agent-bios.mcpb"
|
|
593
|
+
result = {"path": str(path), "dry_run": dry_run, "interpreter": interpreter,
|
|
594
|
+
"desktop_installation": "unverified",
|
|
595
|
+
"next_step": "Open this file with Claude Desktop and confirm its installation dialog; "
|
|
596
|
+
"agent-bios does not write into Desktop."}
|
|
597
|
+
reject_symlink_ancestors(path)
|
|
598
|
+
if dry_run:
|
|
599
|
+
return {**result, "changed": not path.exists()}
|
|
600
|
+
with transaction_lock(self.state_root):
|
|
601
|
+
if path.exists():
|
|
602
|
+
with zipfile.ZipFile(path) as archive:
|
|
603
|
+
if {name: archive.read(name) for name in archive.namelist()} != members:
|
|
604
|
+
raise AppError(f"Desktop bundle collision: {path}")
|
|
605
|
+
return {**result, "changed": False}
|
|
606
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
607
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".agent-bios-", suffix=".mcpb", dir=path.parent)
|
|
608
|
+
try:
|
|
609
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
610
|
+
handle.write(_zip_bytes(members))
|
|
611
|
+
handle.flush()
|
|
612
|
+
os.fsync(handle.fileno())
|
|
613
|
+
os.replace(temporary, path)
|
|
614
|
+
finally:
|
|
615
|
+
if os.path.lexists(temporary):
|
|
616
|
+
os.unlink(temporary)
|
|
617
|
+
return {**result, "changed": True}
|
|
394
618
|
|
|
395
619
|
|
|
396
620
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -404,11 +628,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
404
628
|
command = commands.add_parser(name)
|
|
405
629
|
if name != "status":
|
|
406
630
|
command.add_argument("--dry-run", action="store_true")
|
|
631
|
+
desktop = commands.add_parser("desktop", help="write the Claude Desktop bundle; installing it stays a Desktop step")
|
|
632
|
+
desktop.add_argument("--dry-run", action="store_true")
|
|
407
633
|
session = commands.add_parser("session", help="explicitly preview, use or stop instructions delivery in one app task")
|
|
408
634
|
operations = session.add_subparsers(dest="operation", required=True)
|
|
409
635
|
for name in ("preview", "use", "off", "status"):
|
|
410
636
|
command = operations.add_parser(name)
|
|
411
|
-
command.add_argument("--session", help="defaults to CODEX_THREAD_ID")
|
|
637
|
+
command.add_argument("--session", help="defaults to CODEX_THREAD_ID; Desktop preview and use mint one")
|
|
638
|
+
command.add_argument("--host", choices=sorted(APP_HOSTS), default="codex")
|
|
639
|
+
if name == "status":
|
|
640
|
+
command.add_argument("--end-marker-seen", help="Desktop: the delivery's last line, as the model read it")
|
|
412
641
|
if name in {"preview", "use"}:
|
|
413
642
|
selection = command.add_mutually_exclusive_group()
|
|
414
643
|
selection.add_argument("--domains", help="comma-separated qualified instructions selection")
|
|
@@ -430,8 +659,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
430
659
|
env["AGENT_BIOS_INSTRUCTIONS_DIR"] = str(args.user_dir)
|
|
431
660
|
try:
|
|
432
661
|
if args.command == "session":
|
|
433
|
-
manager = AppSessions(args.repo, env)
|
|
662
|
+
manager = AppSessions(args.repo, env, host=args.host)
|
|
434
663
|
options = {}
|
|
664
|
+
if args.operation == "status" and args.end_marker_seen is not None:
|
|
665
|
+
options["end_marker_seen"] = args.end_marker_seen
|
|
435
666
|
if args.operation in {"preview", "use"}:
|
|
436
667
|
selected = None
|
|
437
668
|
if args.domains is not None:
|
|
@@ -444,6 +675,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
444
675
|
if args.operation == "use":
|
|
445
676
|
options["expected_content_ref"] = args.expected_content_ref
|
|
446
677
|
result = getattr(manager, args.operation)(session=args.session, **options)
|
|
678
|
+
elif args.command == "desktop":
|
|
679
|
+
result = DesktopBundle(args.repo, env).build(dry_run=args.dry_run)
|
|
447
680
|
else:
|
|
448
681
|
manager = AppBridge(args.repo, env)
|
|
449
682
|
options = {} if args.command == "status" else {"dry_run": args.dry_run}
|
|
@@ -573,7 +573,7 @@ def _write_private(path: pathlib.Path, content: str) -> None:
|
|
|
573
573
|
raise CatalogError(f"compiler refuses symlink output: {path}")
|
|
574
574
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
575
575
|
try:
|
|
576
|
-
with path.open('x', encoding='utf-8') as output:
|
|
576
|
+
with path.open('x', encoding='utf-8', newline='\n') as output:
|
|
577
577
|
output.write(content)
|
|
578
578
|
except FileExistsError as exc:
|
|
579
579
|
raise CatalogError(f"compiler output already exists or aliases another member: {path}") from exc
|
|
@@ -586,7 +586,7 @@ def _replace_private_owned(path: pathlib.Path, content: str) -> None:
|
|
|
586
586
|
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
587
587
|
temporary = pathlib.Path(name)
|
|
588
588
|
try:
|
|
589
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
589
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as output:
|
|
590
590
|
output.write(content)
|
|
591
591
|
output.flush()
|
|
592
592
|
os.fsync(output.fileno())
|
|
@@ -5,6 +5,10 @@ meaning, wording and consumption surface; this module checks source provenance,
|
|
|
5
5
|
evidence coverage and publication preconditions for InstructionsStore's transaction.
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
|
+
try:
|
|
9
|
+
from host_platform import sync_directory, cli_argv, redirected
|
|
10
|
+
except ImportError:
|
|
11
|
+
from .host_platform import sync_directory, cli_argv, redirected
|
|
8
12
|
|
|
9
13
|
import copy
|
|
10
14
|
import argparse
|
|
@@ -67,7 +71,7 @@ def _safe_source_path(source: dict[str, Any], *, inspect: bool = True) -> Path:
|
|
|
67
71
|
raise ValidationError("only discovered instruction files may be captured")
|
|
68
72
|
if inspect:
|
|
69
73
|
for member in (root, *(root / Path(*relative.parts[:index]) for index in range(1, len(relative.parts) + 1))):
|
|
70
|
-
if member
|
|
74
|
+
if redirected(member):
|
|
71
75
|
raise ValidationError(f"instruction source is symlinked: {member}")
|
|
72
76
|
return path
|
|
73
77
|
|
|
@@ -125,12 +129,20 @@ def _read_source(source: dict[str, Any]) -> tuple[bytes, str]:
|
|
|
125
129
|
directories = []
|
|
126
130
|
try:
|
|
127
131
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
if os.name == "nt":
|
|
133
|
+
# Reparse points are rejected before and after opening; bind the opened
|
|
134
|
+
# file identity to the selected source below.
|
|
135
|
+
from host_platform import redirected
|
|
136
|
+
if any(redirected(p) for p in (path, *path.parents)):
|
|
137
|
+
raise ValidationError("redirected instruction source")
|
|
138
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_BINARY)
|
|
139
|
+
else:
|
|
140
|
+
directory = os.open(path.anchor, flags | os.O_DIRECTORY)
|
|
132
141
|
directories.append(directory)
|
|
133
|
-
|
|
142
|
+
for part in path.parts[1:-1]:
|
|
143
|
+
directory = os.open(part, flags | os.O_DIRECTORY, dir_fd=directory)
|
|
144
|
+
directories.append(directory)
|
|
145
|
+
descriptor = os.open(path.name, flags | os.O_NONBLOCK, dir_fd=directory)
|
|
134
146
|
with os.fdopen(descriptor, "rb") as stream:
|
|
135
147
|
before = os.fstat(stream.fileno())
|
|
136
148
|
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_SOURCE_BYTES:
|
|
@@ -311,11 +323,7 @@ def capture(store, paths: list[str | Path], *, environ: dict[str, str] | None =
|
|
|
311
323
|
os.link(temporary, path)
|
|
312
324
|
except FileExistsError:
|
|
313
325
|
record = load_capture(store, record["capture_id"])
|
|
314
|
-
|
|
315
|
-
try:
|
|
316
|
-
os.fsync(directory)
|
|
317
|
-
finally:
|
|
318
|
-
os.close(directory)
|
|
326
|
+
sync_directory(path.parent)
|
|
319
327
|
finally:
|
|
320
328
|
temporary.unlink(missing_ok=True)
|
|
321
329
|
return {**record, "review_prompt": review_prompt(record)}
|