agentacct 0.2.0__tar.gz → 0.3.0__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 (111) hide show
  1. {agentacct-0.2.0/src/agentacct.egg-info → agentacct-0.3.0}/PKG-INFO +1 -1
  2. {agentacct-0.2.0 → agentacct-0.3.0}/pyproject.toml +1 -1
  3. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/agent_capabilities.py +25 -12
  4. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/legacy_import.py +1 -0
  5. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/product_parity.py +1 -0
  6. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/cli.py +137 -12
  7. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/client_usage.py +279 -19
  8. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/install_guide.py +2 -1
  9. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/mcp.py +100 -22
  10. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/refreshable_usage.py +1 -0
  11. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/source_discovery.py +33 -7
  12. {agentacct-0.2.0 → agentacct-0.3.0/src/agentacct.egg-info}/PKG-INFO +1 -1
  13. {agentacct-0.2.0 → agentacct-0.3.0}/LICENSE +0 -0
  14. {agentacct-0.2.0 → agentacct-0.3.0}/MANIFEST.in +0 -0
  15. {agentacct-0.2.0 → agentacct-0.3.0}/README.md +0 -0
  16. {agentacct-0.2.0 → agentacct-0.3.0}/setup.cfg +0 -0
  17. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/__init__.py +0 -0
  18. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/activation.py +0 -0
  19. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/agent_loop.py +0 -0
  20. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/agent_smoke.py +0 -0
  21. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/api.py +0 -0
  22. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/autostart.py +0 -0
  23. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/__init__.py +0 -0
  24. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/benchmark.py +0 -0
  25. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/cutover.py +0 -0
  26. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/lane_inventory.py +0 -0
  27. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/legacy_recovery.py +0 -0
  28. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/live_paths.py +0 -0
  29. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/migration_archive.py +0 -0
  30. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/migration_disposition_policy.py +0 -0
  31. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/read_canary.py +0 -0
  32. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/repository.py +0 -0
  33. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/safe_scratch.py +0 -0
  34. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/schema.sql +0 -0
  35. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/snapshot.py +0 -0
  36. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/sqlite.py +0 -0
  37. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/types.py +0 -0
  38. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/unsupported_legacy_routing.py +0 -0
  39. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical/v1_events.py +0 -0
  40. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical_day_cube.py +0 -0
  41. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical_live.py +0 -0
  42. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/canonical_read.py +0 -0
  43. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture/__init__.py +0 -0
  44. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture/adapters.py +0 -0
  45. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture/base.py +0 -0
  46. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture/manifests.py +0 -0
  47. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture/registry.py +0 -0
  48. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture/service.py +0 -0
  49. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/capture_runtime.py +0 -0
  50. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/confidence.py +0 -0
  51. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connector_runtime.py +0 -0
  52. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/__init__.py +0 -0
  53. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/base.py +0 -0
  54. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/control.py +0 -0
  55. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/entire.py +0 -0
  56. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/openlit.py +0 -0
  57. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/paperclip.py +0 -0
  58. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/connectors/registry.py +0 -0
  59. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/context_bridge.py +0 -0
  60. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/control_plane.py +0 -0
  61. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/control_web.py +0 -0
  62. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/cost.py +0 -0
  63. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/deep_provider_validation.py +0 -0
  64. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/env_compat.py +0 -0
  65. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence.py +0 -0
  66. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_html.py +0 -0
  67. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_product.py +0 -0
  68. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_rebuild_activation.py +0 -0
  69. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_rebuild_candidate.py +0 -0
  70. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_rebuild_snapshot.py +0 -0
  71. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_runtime.py +0 -0
  72. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/evidence_store.py +0 -0
  73. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/finding_disposition.py +0 -0
  74. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/hooks.py +0 -0
  75. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/ingestion_health.py +0 -0
  76. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/join_rules.py +0 -0
  77. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/localhost_guard.py +0 -0
  78. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/log_evidence.py +0 -0
  79. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/mcp_client_smoke.py +0 -0
  80. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/mechanical_checks.py +0 -0
  81. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/outcome.py +0 -0
  82. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/platform_support.py +0 -0
  83. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/policy.py +0 -0
  84. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/pricing_catalog.py +0 -0
  85. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/provider_smoke.py +0 -0
  86. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/proxy.py +0 -0
  87. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/reports.py +0 -0
  88. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/runner.py +0 -0
  89. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/service.py +0 -0
  90. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/session_observations.py +0 -0
  91. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/source_paths.py +0 -0
  92. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/source_policy.py +0 -0
  93. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/storage.py +0 -0
  94. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/store_merge.py +0 -0
  95. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/store_resolution.py +0 -0
  96. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/supervisor.py +0 -0
  97. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/task_continuations.py +0 -0
  98. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/task_identity.py +0 -0
  99. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/task_intelligence.py +0 -0
  100. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/task_outcome.py +0 -0
  101. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/task_projection.py +0 -0
  102. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/usage_cube.py +0 -0
  103. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/usage_truth.py +0 -0
  104. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/work_events.py +0 -0
  105. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/work_ledger.py +0 -0
  106. {agentacct-0.2.0 → agentacct-0.3.0}/src/agent_chronicle/wrappers.py +0 -0
  107. {agentacct-0.2.0 → agentacct-0.3.0}/src/agentacct.egg-info/SOURCES.txt +0 -0
  108. {agentacct-0.2.0 → agentacct-0.3.0}/src/agentacct.egg-info/dependency_links.txt +0 -0
  109. {agentacct-0.2.0 → agentacct-0.3.0}/src/agentacct.egg-info/entry_points.txt +0 -0
  110. {agentacct-0.2.0 → agentacct-0.3.0}/src/agentacct.egg-info/requires.txt +0 -0
  111. {agentacct-0.2.0 → agentacct-0.3.0}/src/agentacct.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentacct
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Local-first Agent Work Intelligence for coding agents: usage truth, recorded work, and honest joins
5
5
  Author: mikehasa
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentacct"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Local-first Agent Work Intelligence for coding agents: usage truth, recorded work, and honest joins"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -193,6 +193,15 @@ _OPENCODE_USAGE_FIXTURE = _verification(
193
193
  "tests/test_client_usage.py::test_discover_opencode_usage_reads_json_event_stream_tokens_and_cost",
194
194
  ),
195
195
  )
196
+ _OPENCODE_DB_FIXTURE = _verification(
197
+ "synthetic_fixture",
198
+ verified_at="2026-07-29",
199
+ evidence_refs=(
200
+ "tests/test_client_usage.py::test_discover_opencode_usage_reads_native_session_db",
201
+ "tests/test_client_usage.py::test_discover_opencode_usage_recomputes_cost_from_tokens_when_stored_zero",
202
+ "tests/test_client_usage.py::test_discover_opencode_usage_presence_flags_track_schema_columns",
203
+ ),
204
+ )
196
205
  _OPENCLAW_USAGE_FIXTURE = _verification(
197
206
  "synthetic_fixture",
198
207
  verified_at="2026-07-17",
@@ -439,8 +448,8 @@ _CLIENTS: tuple[dict[str, Any], ...] = (
439
448
  "client": "opencode",
440
449
  "display_name": "OpenCode",
441
450
  "roadmap_phase": "phase_1",
442
- "source_formats": ["captured/exported JSON event stream"],
443
- "session_scope": "Usage-bearing step-finish exports only.",
451
+ "source_formats": ["native opencode.db SQLite session rollup", "captured/exported JSON event stream"],
452
+ "session_scope": "Usage-bearing session rollups (SQLite) or step-finish exports.",
444
453
  "zero_usage_observation": "unavailable",
445
454
  "namespace_hardening": "not_hardened",
446
455
  "verified_stability": _stability(
@@ -453,19 +462,22 @@ _CLIENTS: tuple[dict[str, Any], ...] = (
453
462
  "capabilities": {
454
463
  "session_discovery": _capability(
455
464
  "experimental",
456
- "Session IDs found in captured/exported JSON step-finish streams.",
465
+ "Session IDs from the native opencode.db session rollup, or captured/exported JSON step-finish streams as a fallback.",
457
466
  activation="opt_in_project",
458
- verification=_OPENCODE_USAGE_FIXTURE,
459
- limitations=("The official SQLite store is detected but not imported; zero-token sessions are absent.",),
467
+ verification=_OPENCODE_DB_FIXTURE,
468
+ limitations=("Only usage-bearing sessions are emitted; zero-token sessions are absent.",),
460
469
  ),
461
470
  "usage_import": _capability(
462
471
  "experimental",
463
- "Input, output, reasoning, cache, and client cost from exported step-finish rows.",
472
+ "Per-session input, output, reasoning, and cache token totals from the native SQLite session rollup (JSON export fallback); cost recomputed from tokens when the store records none.",
464
473
  activation="opt_in_project",
465
- verification=_OPENCODE_USAGE_FIXTURE,
466
- limitations=("Synthetic happy-path fixture only; malformed/schema-drift diagnostics are incomplete.",),
474
+ verification=_OPENCODE_DB_FIXTURE,
475
+ limitations=(
476
+ "Synthetic-fixture only; per-message granularity is not imported (session totals only).",
477
+ "OpenCode usually stores cost 0, so cost is typically estimated_from_tokens; a nonzero stored cost is kept as client_reported.",
478
+ ),
467
479
  usage_basis="client_reported",
468
- cost_basis="client_reported",
480
+ cost_basis="estimated_from_tokens",
469
481
  ),
470
482
  "mechanical_capture": _unavailable("No realtime OpenCode plugin adapter is implemented."),
471
483
  "mcp_semantics": _capability(
@@ -477,9 +489,10 @@ _CLIENTS: tuple[dict[str, Any], ...] = (
477
489
  ),
478
490
  "model_attribution": _capability(
479
491
  "experimental",
480
- "First model-like field found in a captured JSON export.",
492
+ "Model id and provider id parsed from the session rollup's model JSON (JSON-export model field as fallback).",
481
493
  activation="opt_in_project",
482
- limitations=("No fixture asserts model extraction; model is not bound to each step-finish row.",),
494
+ verification=_OPENCODE_DB_FIXTURE,
495
+ limitations=("Per-message model binding is not imported; the session rollup carries one model per session.",),
483
496
  ),
484
497
  "cache_read": _capability(
485
498
  "experimental",
@@ -501,7 +514,7 @@ _CLIENTS: tuple[dict[str, Any], ...] = (
501
514
  "agentacct renders the setup command but does not write OpenCode user config or install a plugin."
502
515
  ),
503
516
  },
504
- "limitations": ["Remain experimental until real fixtures, namespace hardening, and official SQLite parsing exist."],
517
+ "limitations": ["Remain experimental until real-client fixtures, namespace hardening, and per-message SQLite granularity exist."],
505
518
  },
506
519
  {
507
520
  "client": "openclaw",
@@ -75,6 +75,7 @@ _LEGACY_CUMULATIVE_USAGE_SEMANTICS = frozenset(
75
75
  CODEX_LINEAGE_DELTA_SEMANTICS,
76
76
  "claude_assistant_message_usage_rows",
77
77
  "opencode_step_finish_events",
78
+ "opencode_session_rollup",
78
79
  "hermes_state_db_session_rows",
79
80
  "openclaw_assistant_usage_rows",
80
81
  }
@@ -670,6 +670,7 @@ _SOURCE_CUMULATIVE_USAGE_SEMANTICS = frozenset(
670
670
  "codex_sqlite_tokens_used_fallback",
671
671
  "claude_assistant_message_usage_rows",
672
672
  "opencode_step_finish_events",
673
+ "opencode_session_rollup",
673
674
  "hermes_state_db_session_rows",
674
675
  "openclaw_assistant_usage_rows",
675
676
  }
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import errno
3
4
  import hashlib
4
5
  import json
5
6
  import math
@@ -7,6 +8,7 @@ import os
7
8
  import shlex
8
9
  import shutil
9
10
  import signal
11
+ import socket
10
12
  import stat
11
13
  import sys
12
14
  import tempfile
@@ -112,7 +114,7 @@ from .hooks import (
112
114
  from .log_evidence import build_log_evidence_index, summarize_log_evidence_donor_rows
113
115
  from . import install_guide
114
116
  from .install_guide import full_prompt as install_guide_full_prompt, one_line_prompt as install_guide_one_line_prompt
115
- from .mcp import TOOLS, SentinelMCPServer, run_mcp_event_workflow_smoke, serve_stdio
117
+ from .mcp import DEGRADED_NO_STORE_MESSAGE, TOOLS, SentinelMCPServer, run_mcp_event_workflow_smoke, serve_stdio
116
118
  from .mcp_client_smoke import MCPClientSmokeError, assert_deepseek_mcp_client_smoke_passed, run_deepseek_mcp_client_smoke
117
119
  from .outcome import (
118
120
  apply_judge_result,
@@ -949,11 +951,34 @@ def _print_codex_mcp_setup(config_store_dir: Path | str, *, command: str = "agen
949
951
  print(_codex_mcp_toml_block(config_store_dir, command=command).rstrip())
950
952
 
951
953
 
954
+ def _print_stale_registration_remediation(agent: str) -> None:
955
+ """Tell the user to remove any leftover pre-rename server before adding agentacct.
956
+
957
+ A registration left over from the ``agent-sentinel`` / ``agent-chronicle``
958
+ era points at a command name that no longer ships (only ``agentacct`` does),
959
+ so the host tries to launch a missing binary — an ENOENT that reads to the
960
+ client as a crashed MCP server. Removing the dead entry first is the fix.
961
+ """
962
+ console.print(
963
+ "First remove any stale pre-rename server — a leftover agent-sentinel/agent-chronicle "
964
+ "entry launches a command that no longer exists (ENOENT), which the client reports as a crash:"
965
+ )
966
+ if agent == "generic":
967
+ console.print(
968
+ "In the agent's MCP config, delete any server named 'agent-sentinel' or 'agent-chronicle' "
969
+ "(only 'agentacct' ships now), then add the definition below."
970
+ )
971
+ return
972
+ print(f"{agent} mcp remove agent-sentinel")
973
+ print(f"{agent} mcp remove agent-chronicle")
974
+
975
+
952
976
  def _print_agent_mcp_preview(agent: str, config_store_dir: Path | str, *, command: str = "agentacct") -> None:
953
977
  quoted_store_dir = shlex.quote(str(config_store_dir))
954
978
  quoted_command = shlex.quote(command)
955
979
  if agent == "generic":
956
980
  console.print("Generic MCP-capable agent")
981
+ _print_stale_registration_remediation(agent)
957
982
  console.print("Use this stdio MCP server definition in the agent's project-local MCP config:")
958
983
  print(_claude_mcp_json(config_store_dir, command=command).rstrip())
959
984
  console.print("Command form:")
@@ -962,16 +987,19 @@ def _print_agent_mcp_preview(agent: str, config_store_dir: Path | str, *, comman
962
987
  if agent == "hermes":
963
988
  console.print("Hermes")
964
989
  console.print("Hermes manages MCP servers in the active Hermes profile, not in this repo.")
990
+ _print_stale_registration_remediation(agent)
965
991
  console.print("Copy/paste command:")
966
992
  print(f"hermes mcp add agentacct --command {quoted_command} --args mcp serve --store-dir {quoted_store_dir}")
967
993
  return
968
994
  if agent == "opencode":
969
995
  console.print("OpenCode")
996
+ _print_stale_registration_remediation(agent)
970
997
  console.print("Copy/paste command:")
971
998
  print(f"opencode mcp add agentacct -- {quoted_command} mcp serve --store-dir {quoted_store_dir}")
972
999
  return
973
1000
  if agent == "openclaw":
974
1001
  console.print("OpenClaw")
1002
+ _print_stale_registration_remediation(agent)
975
1003
  console.print("Copy/paste command:")
976
1004
  print(
977
1005
  "openclaw mcp add agentacct "
@@ -1233,7 +1261,7 @@ def onboard(
1233
1261
  )
1234
1262
  elif opencode_row is not None:
1235
1263
  console.print(
1236
- "OpenCode native database parsing is pending; current OpenCode import requires captured/exported JSON."
1264
+ "OpenCode was detected but no single importable store resolved; ensure one home holds an opencode.db session store (or exported JSON) and select it with `--opencode-home`."
1237
1265
  )
1238
1266
  else:
1239
1267
  console.print("No readable known local usage source was found.")
@@ -3235,15 +3263,31 @@ def mcp_serve(
3235
3263
  """Serve agentacct MCP tools over stdio.
3236
3264
 
3237
3265
  This exposes safe report/outcome/value primitives and does not call paid judge APIs.
3238
- Store resolution is strict: the MCP client controls this process's cwd, so a
3239
- visibly failed server beats a silently wrong ledger.
3266
+ Store resolution stays strict the MCP client controls this process's cwd,
3267
+ so a silently wrong ledger is never acceptable — but a server that EXITS reads
3268
+ to the host as a crash. So an unresolvable store starts a degraded-but-connected
3269
+ session instead: initialize and tools/list still answer, recording tools return
3270
+ a legible "no store configured" error, and no store is ever silently created.
3240
3271
  """
3272
+ # As an MCP server this process's exit is the ONLY liveness signal the host
3273
+ # has: an exit at startup reads as a crash. So an unresolvable store must
3274
+ # NOT exit — it starts a degraded-but-connected session (initialize +
3275
+ # tools/list still answer; recording tools return a legible error). We still
3276
+ # never silently create or pick a store (honesty rule): degraded_reason is
3277
+ # set and no store path is resolved.
3278
+ resolved: Path | None = None
3279
+ degraded_reason: str | None = None
3241
3280
  if store_dir is None:
3242
3281
  try:
3243
3282
  resolved = resolve_store_dir(None).path
3244
3283
  except StoreResolutionError as exc:
3245
- print(str(exc), file=sys.stderr)
3246
- raise typer.Exit(2) from exc
3284
+ print(
3285
+ f"agentacct mcp serve: {exc}\n"
3286
+ "Starting a degraded MCP session (connected, but not recording): restart the server with "
3287
+ f"--store-dir <absolute path> or set {ENV_STORE_DIR}=<absolute path>.",
3288
+ file=sys.stderr,
3289
+ )
3290
+ degraded_reason = DEGRADED_NO_STORE_MESSAGE
3247
3291
  else:
3248
3292
  expanded = store_dir.expanduser()
3249
3293
  if expanded.is_absolute():
@@ -3258,7 +3302,7 @@ def mcp_serve(
3258
3302
  # named the store, which beats a dead server.
3259
3303
  try:
3260
3304
  resolution = resolve_store_dir(None, env={})
3261
- except StoreResolutionError as exc:
3305
+ except StoreResolutionError:
3262
3306
  try:
3263
3307
  env_value = store_env_dir_value(os.environ) or ""
3264
3308
  except StoreResolutionError:
@@ -3284,13 +3328,19 @@ def mcp_serve(
3284
3328
  )
3285
3329
  print(
3286
3330
  "Fix: re-run `agentacct setup mcp --agent <claude-code|codex> --write` in the project root to "
3287
- f"write an absolute store path into the MCP config, or set {ENV_STORE_DIR}=<absolute path>.",
3331
+ f"write an absolute store path into the MCP config, or set {ENV_STORE_DIR}=<absolute path>. "
3332
+ "Starting a degraded MCP session (connected, but not recording) until then.",
3288
3333
  file=sys.stderr,
3289
3334
  )
3290
- raise typer.Exit(2) from exc
3335
+ degraded_reason = DEGRADED_NO_STORE_MESSAGE
3291
3336
  else:
3292
3337
  anchor = resolution.project_root if resolution.project_root is not None else resolution.path.parent.parent
3293
3338
  resolved = anchor / expanded
3339
+ if degraded_reason is not None:
3340
+ # No resolvable store: stay connected but refuse to record. serve_stdio
3341
+ # gets store_dir=None so it never constructs (or creates) a store.
3342
+ serve_stdio(store_dir=None, degraded_reason=degraded_reason)
3343
+ return
3294
3344
  print(f"agentacct mcp serve: store={resolved}", file=sys.stderr)
3295
3345
  serve_stdio(store_dir=resolved)
3296
3346
 
@@ -8006,8 +8056,55 @@ def usage_truth_table_command(
8006
8056
  console.print(f"- {row['integration']}: {row['setup_path']}")
8007
8057
 
8008
8058
 
8059
+ _SERVE_PORT_FALLBACK_SPAN = 20
8060
+ """How many ports past the default the dashboard probes before giving up."""
8061
+
8062
+
8063
+ def _probe_port_free(host: str, port: int) -> bool:
8064
+ """Return True if ``(host, port)`` can be bound right now.
8065
+
8066
+ Mirrors uvicorn's own socket setup (``SO_REUSEADDR``) so the probe reflects
8067
+ what the server will attempt a moment later. There is an unavoidable TOCTOU
8068
+ window between this check and uvicorn's bind, but for a localhost dashboard
8069
+ the practical risk is negligible.
8070
+ """
8071
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
8072
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
8073
+ try:
8074
+ sock.bind((host, port))
8075
+ except OSError:
8076
+ return False
8077
+ return True
8078
+
8079
+
8080
+ def _select_serve_port(
8081
+ host: str,
8082
+ port: int,
8083
+ *,
8084
+ allow_fallback: bool,
8085
+ max_offset: int = _SERVE_PORT_FALLBACK_SPAN,
8086
+ ) -> int:
8087
+ """Pick a bindable port for the local dashboard.
8088
+
8089
+ When ``allow_fallback`` is False (the user passed an explicit ``--port``) the
8090
+ requested port must be free or an ``OSError`` is raised so the caller can
8091
+ fail with an actionable message. When True (the default-port path) a busy
8092
+ port advances through ``port+1 .. port+max_offset`` and the first free port is
8093
+ returned; ``OSError`` is raised only if the whole range is occupied.
8094
+ """
8095
+ if _probe_port_free(host, port):
8096
+ return port
8097
+ if not allow_fallback:
8098
+ raise OSError(errno.EADDRINUSE, f"port {port} is already in use")
8099
+ for candidate in range(port + 1, port + max_offset + 1):
8100
+ if _probe_port_free(host, candidate):
8101
+ return candidate
8102
+ raise OSError(errno.EADDRINUSE, f"no free port in range {port}-{port + max_offset}")
8103
+
8104
+
8009
8105
  @app.command("serve")
8010
8106
  def serve(
8107
+ ctx: typer.Context,
8011
8108
  host: Annotated[str, typer.Option(help="Bind host. Default is 127.0.0.1 for local dashboard safety.")] = "127.0.0.1",
8012
8109
  port: Annotated[int, typer.Option(help="Bind port for the local dashboard and event API.")] = 8765,
8013
8110
  store_dir: Annotated[
@@ -8016,7 +8113,13 @@ def serve(
8016
8113
  ] = None,
8017
8114
  allow_host: Annotated[Optional[list[str]], typer.Option("--allow-host", help=_ALLOW_HOST_HELP)] = None,
8018
8115
  ) -> None:
8019
- """Serve the local dashboard on localhost with local usage discovery enabled."""
8116
+ """Serve the local dashboard on localhost with local usage discovery enabled.
8117
+
8118
+ The default port (8765) auto-advances to the next free port when it is busy,
8119
+ so the dashboard never fails to start just because a port is taken. An
8120
+ explicit ``--port`` is honored strictly: if that exact port is occupied the
8121
+ command fails rather than silently moving.
8122
+ """
8020
8123
  import uvicorn
8021
8124
 
8022
8125
  if host not in {"127.0.0.1", "localhost"}:
@@ -8026,7 +8129,29 @@ def serve(
8026
8129
  # not a server crash mid-startup.
8027
8130
  resolution = _resolve_dashboard_cli_store_dir(store_dir)
8028
8131
  effective_store_dir = resolution.path
8029
- console.print(f"Starting agentacct dashboard: http://{host}:{port}")
8132
+ # Only auto-advance when the port came from the default; an explicit --port
8133
+ # is a specific request and must fail loudly instead of moving. Compare the
8134
+ # click ParameterSource by name -- typer's runtime enum is not identical to
8135
+ # click.core's, so identity/value comparisons are unreliable across versions.
8136
+ port_source = ctx.get_parameter_source("port")
8137
+ port_explicitly_set = getattr(port_source, "name", "DEFAULT") != "DEFAULT"
8138
+ try:
8139
+ bound_port = _select_serve_port(host, port, allow_fallback=not port_explicitly_set)
8140
+ except OSError:
8141
+ if port_explicitly_set:
8142
+ console.print(
8143
+ f"Port {port} is already in use. Pass a free --port, or omit --port to let "
8144
+ "agentacct pick the next free port automatically."
8145
+ )
8146
+ else:
8147
+ console.print(
8148
+ f"Ports {port}-{port + _SERVE_PORT_FALLBACK_SPAN} are all in use. "
8149
+ "Free one up or pass an explicit --port."
8150
+ )
8151
+ raise typer.Exit(1)
8152
+ if bound_port != port:
8153
+ console.print(f"Port {port} was busy; dashboard on http://{host}:{bound_port}")
8154
+ console.print(f"Starting agentacct dashboard: http://{host}:{bound_port}")
8030
8155
  if resolution.source == "global":
8031
8156
  console.print("Dashboard scope: All projects (machine-wide store).")
8032
8157
  _warn_dashboard_mcp_store_shadow(effective_store_dir)
@@ -8045,7 +8170,7 @@ def serve(
8045
8170
  extra_allowed_hosts=tuple(allow_host or ()),
8046
8171
  ),
8047
8172
  host=host,
8048
- port=port,
8173
+ port=bound_port,
8049
8174
  log_level="info",
8050
8175
  )
8051
8176