agentic-runner 3.12.0__tar.gz → 3.12.2__tar.gz

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 (74) hide show
  1. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/PKG-INFO +1 -1
  2. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/cli.py +21 -0
  4. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/acp_runtime.py +155 -17
  5. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/settings.py +3 -1
  6. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workstation.py +28 -1
  7. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/.gitignore +0 -0
  8. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/LICENSE +0 -0
  9. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/README.md +0 -0
  10. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/pyproject.toml +0 -0
  11. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/activities.py +0 -0
  12. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/attempts.py +0 -0
  13. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/auth_mode.py +0 -0
  14. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/build.py +0 -0
  15. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/callback.py +0 -0
  16. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/child_watcher.py +0 -0
  17. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/cli_floor.py +0 -0
  18. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/config.py +0 -0
  19. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/credentials.py +0 -0
  20. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/device_login_activities.py +0 -0
  21. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/egress.py +0 -0
  22. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/harness_self_test.py +0 -0
  23. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/heartbeat_link.py +0 -0
  24. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/hooks.py +0 -0
  25. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/host_store.py +0 -0
  26. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/__init__.py +0 -0
  27. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/git/__init__.py +0 -0
  28. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/git/contracts.py +0 -0
  29. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/git/evidence.py +0 -0
  30. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  31. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/git/workspace.py +0 -0
  32. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/github/__init__.py +0 -0
  33. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/github/auth.py +0 -0
  34. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  35. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  36. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  37. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  38. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/llm_proxy.py +0 -0
  40. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/mcp.py +0 -0
  41. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/message_store.py +0 -0
  42. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/oauth_connectors.py +0 -0
  43. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/private_state.py +0 -0
  44. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/py.typed +0 -0
  45. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/recipient_key_secret.py +0 -0
  46. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/registration.py +0 -0
  47. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/runtime/__init__.py +0 -0
  48. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/runtime/verifier_command.py +0 -0
  49. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/sealed_box.py +0 -0
  50. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/service.py +0 -0
  51. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/testing/__init__.py +0 -0
  52. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/testing/__main__.py +0 -0
  53. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/testing/control_plane.py +0 -0
  54. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/testing/plugin.py +0 -0
  55. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/testing/test_conformance.py +0 -0
  56. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/testing/workflow.py +0 -0
  57. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/tiny_http.py +0 -0
  58. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/triage_activities.py +0 -0
  59. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/usage_windows.py +0 -0
  60. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/user_sources.py +0 -0
  61. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/__init__.py +0 -0
  62. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/_runtime_support.py +0 -0
  63. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/agent_runtime.py +0 -0
  64. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/claude_runtime.py +0 -0
  65. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  66. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/codex_runtime.py +0 -0
  67. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/command_policy.py +0 -0
  68. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/contract_device_login.py +0 -0
  69. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/contract_isolation.py +0 -0
  70. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/fastapi_client.py +0 -0
  71. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/harness_outcome.py +0 -0
  72. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/harness_usage.py +0 -0
  73. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/mcp_config.py +0 -0
  74. {agentic_runner-3.12.0 → agentic_runner-3.12.2}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.12.0
3
+ Version: 3.12.2
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.12.0"
10
+ __version__ = "3.12.2"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -158,6 +158,12 @@ def main(argv: Sequence[str] | None = None) -> int:
158
158
  action="store_true",
159
159
  help="no TLS to Temporal -- a local development server only",
160
160
  )
161
+ install.add_argument(
162
+ "--acp-cli-kinds",
163
+ default=None,
164
+ help="comma-separated cli_kinds served through the pinned ACP bridge (codex_cli, "
165
+ "claude_code); omitted keeps the previous install's, '' turns ACP off",
166
+ )
161
167
  install.add_argument("--no-start", action="store_true", help="write the agent, do not start")
162
168
  install.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
163
169
  for service_verb in ("start", "stop"):
@@ -342,6 +348,11 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
342
348
  if not arguments.control_plane:
343
349
  print(f"--control-plane (or {CONTROL_PLANE_ENV}) is required", file=sys.stderr)
344
350
  return 2
351
+ try:
352
+ acp_cli_kinds = _acp_cli_kinds(arguments.acp_cli_kinds, paths)
353
+ except ValueError as error:
354
+ print(str(error), file=sys.stderr)
355
+ return 2
345
356
  # Pasted, never a flag: a flag is in the process table for every uid on the box.
346
357
  token = os.environ.get(AGENT_TOKEN_ENV, "").strip() or getpass.getpass(
347
358
  "Agent Token (yours from /me/runners, or the Organisation Admin's for an "
@@ -354,6 +365,7 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
354
365
  temporal_tls=not arguments.temporal_plaintext,
355
366
  path=os.environ.get("PATH", ""),
356
367
  install_channel=workstation.install_channel(),
368
+ acp_cli_kinds=acp_cli_kinds,
357
369
  )
358
370
  try:
359
371
  lines = asyncio.run(
@@ -369,6 +381,15 @@ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
369
381
  return 0
370
382
 
371
383
 
384
+ def _acp_cli_kinds(flag: str | None, paths: workstation.OrgPaths) -> str:
385
+ # A reinstall to pick up a new PATH or retry a token must not turn ACP off (QTS-1355).
386
+ if flag is not None:
387
+ return workstation.parse_acp_cli_kinds(flag)
388
+ if not paths.settings_file.is_file():
389
+ return ""
390
+ return workstation.WorkstationSettings.load(paths).acp_cli_kinds
391
+
392
+
372
393
  def _callback(arguments: argparse.Namespace) -> int:
373
394
  socket_path = os.environ.get(CALLBACK_SOCKET_ENV, "")
374
395
  token = os.environ.get(CALLBACK_TOKEN_ENV, "")
@@ -34,7 +34,19 @@ Facts this module stands on (the LA-02 spike, re-read against the pinned bridge
34
34
  is refused, and a harness-root ``config.toml`` may hold only ``_CODEX_HARNESS_ROOT_KEYS``
35
35
  (LA-19): nothing may load that the Runner did not choose.
36
36
  - claude-agent-acp loads the Workspace's settings, hooks, ``.mcp.json`` and ``CLAUDE.md``
37
- unless ``session/new`` carries the three options in ``_CLAUDE_SESSION_OPTIONS``.
37
+ unless ``session/new`` carries the three options in ``_CLAUDE_SESSION_OPTIONS``. The
38
+ harness root is the Contract's uid's to write, and its ``user`` source is more than
39
+ ``settings.json``: a skill's or command's ``allowed-tools`` is an allow rule, and an
40
+ agent, a hook or ``CLAUDE.md`` would steer every later Directive of that Contract. So no
41
+ setting source loads at all, the ``user`` one included: measured on claude 2.1.280, the
42
+ harness root's skills, commands, agents, ``CLAUDE.md`` and settings stay out while its
43
+ ``.credentials.json`` login still signs the requests (LA-12d item 2). The bridge itself
44
+ still reads the harness root's ``settings.json`` for the initial mode, bypass and model
45
+ list, so that file may hold only ``_CLAUDE_HARNESS_ROOT_KEYS``.
46
+ - A Claude permission request for an MCP tool names no command. It carries the serving
47
+ server in ``_meta.claudeCode.mcpServer``, and ``source: "dynamic"`` is a server from the
48
+ CLI's ``--mcp-config``: the ones ``session/new`` sent, the only ones ``strictMcpConfig``
49
+ loads. A granted server's tool is allowed (LA-12d item 1).
38
50
 
39
51
  A ``cli_kind`` with no pinned bridge (local-agents 18) runs the command its Agent Runtime
40
52
  Profile names, and only on a Runner whose host pinned that kind's executable in
@@ -187,9 +199,21 @@ _CODEX_HARNESS_ROOT_KEYS: Final[frozenset[str]] = frozenset(
187
199
  {"model", "model_reasoning_effort", "model_reasoning_summary", "model_verbosity", "notice"}
188
200
  )
189
201
 
202
+ # What a harness-root `settings.json` may set: the choices Claude Code writes there itself.
203
+ # The CLI no longer loads it, but the bridge does; `permissions`, `hooks`, `env`,
204
+ # `apiKeyHelper`, `enabledPlugins` and the rest would be the Contract's own file steering it.
205
+ _CLAUDE_HARNESS_ROOT_KEYS: Final[frozenset[str]] = frozenset(
206
+ {"$schema", "model", "effortLevel", "alwaysThinkingEnabled"}
207
+ )
208
+ # The `mcpServer.source` of a server the CLI got from `--mcp-config`, which is where the
209
+ # bridge puts `session/new`'s servers. A name alone is the config key as authored, so a
210
+ # server from any other source could reuse a granted slug.
211
+ _CLAUDE_SENT_MCP_SOURCE: Final[str] = "dynamic"
212
+
190
213
  _CLAUDE_SESSION_OPTIONS: Final[Mapping[str, object]] = {
191
- # The harness root's own settings only: no Workspace settings, hooks or CLAUDE.md.
192
- "settingSources": ["user"],
214
+ # No settings file, hook, skill, command, agent or CLAUDE.md from anywhere, the
215
+ # Contract's harness root included; the login is not a setting source and still loads.
216
+ "settingSources": [],
193
217
  # Only the servers sent over ACP; never the Workspace's `.mcp.json`.
194
218
  "strictMcpConfig": True,
195
219
  # Bypass is never offered as a mode, whatever a settings file asks for.
@@ -267,6 +291,7 @@ class _Turn:
267
291
  output: list[str] = field(default_factory=list)
268
292
  notes: list[str] = field(default_factory=list)
269
293
  held: bool = False
294
+ session_id: str = ""
270
295
  usage_windows: tuple[UsageWindow, ...] = ()
271
296
 
272
297
 
@@ -389,6 +414,10 @@ class AcpRuntime:
389
414
  )
390
415
  if config_refusal:
391
416
  return refuse("refused: Codex config file", config_refusal)
417
+ if self._is_claude and harness_root is not None:
418
+ settings_refusal = _claude_settings_refusal(harness_root)
419
+ if settings_refusal:
420
+ return refuse("refused: Claude settings file", settings_refusal)
392
421
 
393
422
  # The activity refuses this first, with Evidence; checked again where it runs.
394
423
  command_refusal = self.acp_command_refusal(request.acp_command)
@@ -539,7 +568,7 @@ class AcpRuntime:
539
568
  with recorded_group(process.pid):
540
569
  try:
541
570
  stop_reason, model_usage, error = await asyncio.wait_for(
542
- self._turn(connection, request, workspace_path, extra_env),
571
+ self._turn(connection, request, workspace_path, extra_env, turn),
543
572
  timeout=self._timeout_seconds,
544
573
  )
545
574
  finally:
@@ -551,6 +580,17 @@ class AcpRuntime:
551
580
  await terminate_process_tree(process)
552
581
  raise
553
582
  stderr = await stderr_task
583
+ if self._is_codex and turn.session_id:
584
+ # After the bridge exited, so Codex has flushed the rollout, and before the
585
+ # api_key attempt root holding it is removed.
586
+ codex_home = Path(env[ACP_BRIDGES["codex_cli"].config_root_env])
587
+ total = await asyncio.to_thread(
588
+ _codex_rollout_usage, codex_home, turn.session_id, model_usage
589
+ )
590
+ if total is None:
591
+ turn.notes.append("usage: the bridge's last model call only; no rollout total")
592
+ else:
593
+ model_usage = total
554
594
  return stop_reason, model_usage, stderr, error
555
595
 
556
596
  async def _turn(
@@ -559,6 +599,7 @@ class AcpRuntime:
559
599
  request: DirectiveRequest,
560
600
  workspace_path: Path,
561
601
  extra_env: Mapping[str, str],
602
+ turn: _Turn,
562
603
  ) -> tuple[str, tuple[ModelUsage, ...], str]:
563
604
  try:
564
605
  await connection.request(
@@ -584,7 +625,7 @@ class AcpRuntime:
584
625
  }
585
626
  }
586
627
  session = await connection.request("session/new", session_params)
587
- session_id = str(session.get("sessionId") or "")
628
+ session_id = turn.session_id = str(session.get("sessionId") or "")
588
629
  if self._is_claude and _offers_mode(session, _CLAUDE_MODE):
589
630
  await connection.request(
590
631
  "session/set_mode", {"sessionId": session_id, "modeId": _CLAUDE_MODE}
@@ -625,19 +666,22 @@ class AcpRuntime:
625
666
  turn: _Turn,
626
667
  ) -> Mapping[str, Any]:
627
668
  tool_call = params.get("toolCall") or {}
628
- raw_input = tool_call.get("rawInput") if isinstance(tool_call, Mapping) else None
629
- argv, cwd = _requested_command(raw_input, workspace_path)
630
- described = (
631
- shlex.join(argv)
632
- if argv
633
- else str(tool_call.get("title") or "an action")
634
- if isinstance(tool_call, Mapping)
635
- else "an action"
669
+ title = str(tool_call.get("title") or "") if isinstance(tool_call, Mapping) else ""
670
+ mcp_server = (
671
+ _granted_mcp_server(tool_call, request.mcp_servers or ()) if self._is_claude else None
636
672
  )
637
- described = _redact(described)
638
- # The Directive's own Workspace, not WORKSPACE_ROOT: without a Contract uid nothing
639
- # else keeps an allowlisted command out of another Work Record's Workspace.
640
- verdict, reason = _verdict(argv, cwd, request, workspace_path)
673
+ if mcp_server is not None:
674
+ # Checked first: an MCP tool's arguments are not a command, even one named so.
675
+ described = _redact(f"{title} (MCP server {mcp_server})")
676
+ verdict, reason = _Verdict.ALLOW, "a granted MCP server's tool"
677
+ else:
678
+ raw_input = tool_call.get("rawInput") if isinstance(tool_call, Mapping) else None
679
+ argv, cwd = _requested_command(raw_input, workspace_path)
680
+ described = _redact(shlex.join(argv) if argv else title or "an action")
681
+ # The Directive's own Workspace, not WORKSPACE_ROOT: without a Contract uid
682
+ # nothing else keeps an allowlisted command out of another Work Record's
683
+ # Workspace.
684
+ verdict, reason = _verdict(argv, cwd, request, workspace_path)
641
685
  options = params.get("options") or []
642
686
  if verdict == _Verdict.ALLOW:
643
687
  turn.notes.append(f"permission allowed: {described}")
@@ -879,6 +923,44 @@ def _codex_config_refusal(workspace_path: Path, harness_root: Path | None) -> st
879
923
  return None
880
924
 
881
925
 
926
+ def _claude_settings_refusal(harness_root: Path) -> str | None:
927
+ """Why the harness root's ``settings.json`` refuses a Claude Directive, or None.
928
+
929
+ As ``_codex_config_refusal``: the key names, never the path, reach the control plane.
930
+ """
931
+
932
+ try:
933
+ text = (harness_root / "settings.json").read_text(encoding="utf-8")
934
+ except FileNotFoundError:
935
+ return None
936
+ except OSError:
937
+ return "the harness root's settings.json cannot be read"
938
+ try:
939
+ settings = json.loads(text)
940
+ except ValueError:
941
+ return "the harness root's settings.json does not parse"
942
+ if not isinstance(settings, dict):
943
+ return "the harness root's settings.json is not an object"
944
+ unexpected = sorted(set(settings) - _CLAUDE_HARNESS_ROOT_KEYS)
945
+ if unexpected:
946
+ return f"the harness root's settings.json sets {', '.join(unexpected)}; remove them"
947
+ return None
948
+
949
+
950
+ def _granted_mcp_server(tool_call: object, granted: Sequence[McpServerEntry]) -> str | None:
951
+ """The granted server a Claude permission request's MCP tool is served by, or None."""
952
+
953
+ if not isinstance(tool_call, Mapping):
954
+ return None
955
+ meta = tool_call.get("_meta")
956
+ claude = meta.get("claudeCode") if isinstance(meta, Mapping) else None
957
+ server = claude.get("mcpServer") if isinstance(claude, Mapping) else None
958
+ if not isinstance(server, Mapping) or server.get("source") != _CLAUDE_SENT_MCP_SOURCE:
959
+ return None
960
+ name = server.get("name")
961
+ return name if isinstance(name, str) and name in {e.slug for e in granted} else None
962
+
963
+
882
964
  @contextlib.contextmanager
883
965
  def _attempt_codex_home(
884
966
  proxy_url: str, harness_root: Path, sandbox: DirectiveSandbox | None
@@ -1034,6 +1116,62 @@ def _model_usage(response: Mapping[str, Any]) -> tuple[ModelUsage, ...]:
1034
1116
  return tuple(usage)
1035
1117
 
1036
1118
 
1119
+ def _codex_rollout_usage(
1120
+ codex_home: Path, session_id: str, reported: tuple[ModelUsage, ...]
1121
+ ) -> tuple[ModelUsage, ...] | None:
1122
+ """The whole turn's usage from the Codex thread's rollout, or None if it has none.
1123
+
1124
+ codex-acp 2.1.1 reports ``tokenUsage.last`` -- the turn's last model call -- as the
1125
+ prompt response's ``model_usage`` (LA-12d item 4). One Directive is one session with
1126
+ one turn, so the thread's ``total_token_usage`` is that turn's. The ACP session id is
1127
+ the Codex thread id, which names the rollout file.
1128
+ """
1129
+
1130
+ # The id goes into a glob, and a subscription harness root keeps other Directives'
1131
+ # rollouts: anything but a thread id could match, and meter, one of theirs.
1132
+ try:
1133
+ uuid.UUID(session_id)
1134
+ except ValueError:
1135
+ return None
1136
+ total: Mapping[str, Any] | None = None
1137
+ model = reported[0].model if reported else None
1138
+ try:
1139
+ for path in (codex_home / "sessions").rglob(f"rollout-*-{session_id}.jsonl"):
1140
+ with path.open(encoding="utf-8") as rollout:
1141
+ for line in rollout:
1142
+ try:
1143
+ entry = json.loads(line)
1144
+ except ValueError:
1145
+ continue
1146
+ payload = entry.get("payload") if isinstance(entry, dict) else None
1147
+ if not isinstance(payload, dict):
1148
+ continue
1149
+ if entry.get("type") == "turn_context" and isinstance(
1150
+ payload.get("model"), str
1151
+ ):
1152
+ model = model or payload["model"]
1153
+ info = payload.get("info") if payload.get("type") == "token_count" else None
1154
+ usage = info.get("total_token_usage") if isinstance(info, dict) else None
1155
+ if isinstance(usage, dict):
1156
+ total = usage
1157
+ except OSError:
1158
+ return None
1159
+ if total is None or model is None:
1160
+ return None
1161
+ cached = _count(total, "cached_input_tokens")
1162
+ return (
1163
+ ModelUsage(
1164
+ model=model,
1165
+ # Net of the cached part, as the bridge's own count is.
1166
+ input_tokens=max(_count(total, "input_tokens") - cached, 0),
1167
+ output_tokens=_count(total, "output_tokens"),
1168
+ cached_read_tokens=cached,
1169
+ cached_write_tokens=_count(total, "cache_write_input_tokens"),
1170
+ reasoning_output_tokens=_count(total, "reasoning_output_tokens"),
1171
+ ),
1172
+ )
1173
+
1174
+
1037
1175
  def _count(values: Mapping[str, Any], key: str) -> int:
1038
1176
  value = values.get(key)
1039
1177
  return int(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0
@@ -137,7 +137,9 @@ class WorkerSettings(BaseSettings):
137
137
  default="",
138
138
  description=(
139
139
  "Comma-separated cli_kinds (`codex_cli`, `claude_code`) this Runner serves through "
140
- "the pinned ACP bridge instead of the per-CLI runtime (local-agents 12)."
140
+ "the pinned ACP bridge instead of the per-CLI runtime (local-agents 12). Never "
141
+ "`codex_cli` on a Runner pod: its bwrap sandbox cannot start under the default "
142
+ "seccomp profile (LA-12d item 3)."
141
143
  ),
142
144
  )
143
145
  ACP_PROFILE_CLI_KINDS: str = Field(
@@ -50,6 +50,7 @@ from agentic_runner.host_store import open_workstation_store
50
50
  from agentic_runner.lifecycle import LifecycleOutbox
51
51
  from agentic_runner.registration import load_state
52
52
  from agentic_runner.service import HEARTBEAT_STAMP_NAME, PIDFILE_NAME
53
+ from agentic_runner.workers.acp_runtime import ACP_BRIDGES
53
54
  from agentic_runner_contracts.runner_registration import (
54
55
  CliVersion,
55
56
  EgressPosture,
@@ -75,6 +76,7 @@ __all__ = [
75
76
  "install",
76
77
  "install_channel",
77
78
  "locate_clis",
79
+ "parse_acp_cli_kinds",
78
80
  "require_any_cli",
79
81
  "run_environment",
80
82
  "run_process",
@@ -204,6 +206,9 @@ class WorkstationSettings(BaseModel):
204
206
  # would not find a Homebrew or `~/.local/bin` CLI without it.
205
207
  path: str
206
208
  install_channel: InstallChannel
209
+ # The Runner's `ACP_CLI_KINDS`, kept here rather than hand-added to the login agent,
210
+ # which every `install` rewrites (QTS-1355).
211
+ acp_cli_kinds: str = ""
207
212
 
208
213
  def save(self, paths: OrgPaths) -> None:
209
214
  paths.settings_file.write_text(self.model_dump_json(indent=2), encoding="utf-8")
@@ -218,6 +223,23 @@ class WorkstationSettings(BaseModel):
218
223
  return cls.model_validate_json(paths.settings_file.read_text(encoding="utf-8"))
219
224
 
220
225
 
226
+ def parse_acp_cli_kinds(value: str) -> str:
227
+ """``--acp-cli-kinds``, refused if it names a kind with no pinned bridge.
228
+
229
+ The Runner itself drops such a kind without a word, so a typo here would turn ACP off
230
+ as quietly as the rewritten login agent did (QTS-1355).
231
+ """
232
+
233
+ kinds = [kind.strip() for kind in value.split(",") if kind.strip()]
234
+ unknown = sorted(set(kinds) - ACP_BRIDGES.keys())
235
+ if unknown:
236
+ raise ValueError(
237
+ f"--acp-cli-kinds names {', '.join(unknown)}; a pinned ACP bridge exists only for "
238
+ f"{', '.join(sorted(ACP_BRIDGES))}"
239
+ )
240
+ return ",".join(dict.fromkeys(kinds))
241
+
242
+
221
243
  def install_channel(
222
244
  platform: str = sys.platform, *, executable: str = sys.executable
223
245
  ) -> InstallChannel:
@@ -235,7 +257,7 @@ def run_environment(paths: OrgPaths, settings: WorkstationSettings) -> dict[str,
235
257
  a unit, a task -- stays a one-liner, and the three OSes cannot drift apart.
236
258
  """
237
259
 
238
- return {
260
+ environment = {
239
261
  "PATH": settings.path,
240
262
  "AGENTIC_RUNNER_STATE_DIR": str(paths.state_dir),
241
263
  "AGENTIC_RUNNER_WORKSPACE_ROOT": str(paths.workspaces),
@@ -254,6 +276,11 @@ def run_environment(paths: OrgPaths, settings: WorkstationSettings) -> dict[str,
254
276
  # port inherited from a pod-style environment would collide on the second one.
255
277
  service.READINESS_PORT_ENV: "0",
256
278
  }
279
+ # Only when set: a login agent hand-edited with `ACP_CLI_KINDS` before QTS-1355 keeps
280
+ # ACP on across an upgrade until its next `install`.
281
+ if settings.acp_cli_kinds:
282
+ environment["ACP_CLI_KINDS"] = settings.acp_cli_kinds
283
+ return environment
257
284
 
258
285
 
259
286
  # ------------------------------------------------------------------ the CLIs
File without changes