firekeep-client 1.7.2__tar.gz → 1.8.1__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 (182) hide show
  1. {firekeep_client-1.7.2/firekeep_client.egg-info → firekeep_client-1.8.1}/PKG-INFO +4 -4
  2. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/README.md +3 -3
  3. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/__init__.py +1 -1
  4. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/codex.py +3 -0
  5. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/childenv.py +4 -1
  6. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/cli.py +70 -4
  7. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/gateway.py +9 -0
  8. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/session_start.py +2 -0
  9. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/promptrecall.py +13 -1
  10. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/report.py +146 -10
  11. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/shim.py +23 -7
  12. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/state.py +85 -0
  13. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/updater.py +2 -2
  14. {firekeep_client-1.7.2 → firekeep_client-1.8.1/firekeep_client.egg-info}/PKG-INFO +4 -4
  15. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/SOURCES.txt +3 -1
  16. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/pyproject.toml +1 -1
  17. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_consent_pty.py +20 -5
  18. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_install.py +40 -2
  19. firekeep_client-1.8.1/tests/test_cli_whoami.py +101 -0
  20. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_e2e_bootstrap.py +2 -2
  21. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway_child_env.py +6 -0
  22. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway_toolset.py +41 -0
  23. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_package.py +1 -1
  24. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_promptrecall.py +24 -0
  25. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_consent_surfaces.py +1 -1
  26. firekeep_client-1.8.1/tests/test_report_milestones.py +177 -0
  27. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_identity.py +124 -0
  28. firekeep_client-1.8.1/tests/test_state_session_report.py +106 -0
  29. firekeep_client-1.7.2/tests/test_ci_publishes_symdex.py +0 -30
  30. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/LICENSE +0 -0
  31. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/NOTICE +0 -0
  32. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/__init__.py +0 -0
  33. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/base.py +0 -0
  34. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/claude.py +0 -0
  35. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/claude_desktop.py +0 -0
  36. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/generic.py +0 -0
  37. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/kiro.py +0 -0
  38. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/opencode.py +0 -0
  39. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/adapters/pi.py +0 -0
  40. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/autoupdate.py +0 -0
  41. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/background.py +0 -0
  42. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/backups.py +0 -0
  43. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/config_write.py +0 -0
  44. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/connect.py +0 -0
  45. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/contract/__init__.py +0 -0
  46. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/contract/matrix.py +0 -0
  47. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/decision/__init__.py +0 -0
  48. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/decision/board.py +0 -0
  49. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/decision/server.py +0 -0
  50. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/dexes.py +0 -0
  51. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/docdexsync.py +0 -0
  52. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooklog.py +0 -0
  53. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/__init__.py +0 -0
  54. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/__main__.py +0 -0
  55. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/_git.py +0 -0
  56. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/_mcp.py +0 -0
  57. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/destructive.py +0 -0
  58. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/post_tool.py +0 -0
  59. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/pre_tool.py +0 -0
  60. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/precompact.py +0 -0
  61. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/prompt.py +0 -0
  62. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/runbooks.py +0 -0
  63. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/session_end.py +0 -0
  64. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/hooks/stop.py +0 -0
  65. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/jobobject.py +0 -0
  66. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/join.py +0 -0
  67. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/joincode.py +0 -0
  68. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/maildexsync.py +0 -0
  69. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/migrate.py +0 -0
  70. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/nightshift.py +0 -0
  71. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/nightshiftdrain.py +0 -0
  72. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/pathenv.py +0 -0
  73. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/resolver.py +0 -0
  74. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/serverinit.py +0 -0
  75. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/serverupdate.py +0 -0
  76. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/sidecar.py +0 -0
  77. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/signing.py +0 -0
  78. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/stdio.py +0 -0
  79. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/symdexindex.py +0 -0
  80. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/transport.py +0 -0
  81. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/wizard.py +0 -0
  82. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client/worktree_snapshot.py +0 -0
  83. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/dependency_links.txt +0 -0
  84. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/entry_points.txt +0 -0
  85. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/requires.txt +0 -0
  86. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/firekeep_client.egg-info/top_level.txt +0 -0
  87. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/setup.cfg +0 -0
  88. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_attribution_headers.py +0 -0
  89. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_autoupdate.py +0 -0
  90. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_background.py +0 -0
  91. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bash_layer_retired.py +0 -0
  92. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_docdex.py +0 -0
  93. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_maildex.py +0 -0
  94. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_ps1.py +0 -0
  95. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_reinstall.py +0 -0
  96. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_sh.py +0 -0
  97. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_symdex.py +0 -0
  98. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_bootstrap_venv_provisioning.py +0 -0
  99. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli.py +0 -0
  100. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_backup.py +0 -0
  101. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_dex.py +0 -0
  102. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_docdex.py +0 -0
  103. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_doctor.py +0 -0
  104. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_doctor_generic.py +0 -0
  105. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_doctor_report.py +0 -0
  106. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_entry.py +0 -0
  107. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_generic_install.py +0 -0
  108. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_hands.py +0 -0
  109. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_maildex.py +0 -0
  110. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_onboarding.py +0 -0
  111. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_personal.py +0 -0
  112. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_uninstall.py +0 -0
  113. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_uninstall_generic.py +0 -0
  114. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_cli_update.py +0 -0
  115. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_config_migration.py +0 -0
  116. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_connect.py +0 -0
  117. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_board_html.py +0 -0
  118. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_bypass.py +0 -0
  119. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_poll_ceiling.py +0 -0
  120. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_registration.py +0 -0
  121. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_decision_server.py +0 -0
  122. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_dexes.py +0 -0
  123. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_docdexsync.py +0 -0
  124. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_docs_reference_client_kit.py +0 -0
  125. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_doctor_credential_expiry.py +0 -0
  126. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_doctor_embeddings.py +0 -0
  127. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_doctor_no_server.py +0 -0
  128. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway.py +0 -0
  129. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_gateway_schema_slimming.py +0 -0
  130. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_generic_config.py +0 -0
  131. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_hooklog.py +0 -0
  132. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_import_boundary.py +0 -0
  133. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_init_self_enroll.py +0 -0
  134. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_init_stale_bundle.py +0 -0
  135. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_install_skill_contract.py +0 -0
  136. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_instruction_budget.py +0 -0
  137. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_jobobject.py +0 -0
  138. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_join.py +0 -0
  139. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_join_config_write.py +0 -0
  140. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_joincode.py +0 -0
  141. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_kit_smoke.py +0 -0
  142. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_maildexsync.py +0 -0
  143. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_make_release.py +0 -0
  144. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_memory_instructions.py +0 -0
  145. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_migration_entrypoints.py +0 -0
  146. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_nightshift.py +0 -0
  147. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_nightshiftdrain.py +0 -0
  148. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_pathenv.py +0 -0
  149. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_bootstrap_enums.py +0 -0
  150. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_capture.py +0 -0
  151. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_consent.py +0 -0
  152. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_flush_points.py +0 -0
  153. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_integration.py +0 -0
  154. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_schema.py +0 -0
  155. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_report_spool.py +0 -0
  156. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_resolver.py +0 -0
  157. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_resolver_bypass.py +0 -0
  158. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_resolver_single_connection.py +0 -0
  159. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_runbook_match_parity.py +0 -0
  160. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_runbook_state.py +0 -0
  161. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_scratch_ttl.py +0 -0
  162. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_serverinit.py +0 -0
  163. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_serverupdate.py +0 -0
  164. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_bridge.py +0 -0
  165. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_bypass.py +0 -0
  166. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_eof_exit.py +0 -0
  167. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_fail_loud.py +0 -0
  168. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_recovery.py +0 -0
  169. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_shim_skeleton.py +0 -0
  170. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_sidecar.py +0 -0
  171. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_sidecar_robustness.py +0 -0
  172. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_signing.py +0 -0
  173. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_state.py +0 -0
  174. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_stdio_encoding.py +0 -0
  175. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_symdexindex.py +0 -0
  176. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_transport.py +0 -0
  177. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_transport_sse.py +0 -0
  178. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_twin_removed.py +0 -0
  179. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_updater.py +0 -0
  180. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_wizard.py +0 -0
  181. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_wizard_generic.py +0 -0
  182. {firekeep_client-1.7.2 → firekeep_client-1.8.1}/tests/test_worktree_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firekeep-client
3
- Version: 1.7.2
3
+ Version: 1.8.1
4
4
  Summary: Client for Firekeep, the self-hosted operating layer for connected AI agents.
5
5
  License-Expression: LicenseRef-Firekeep-BUSL-1.1
6
6
  Project-URL: Homepage, https://firekeep.ai
@@ -57,9 +57,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
57
57
  the off-switch, and removals stick across updates.
58
58
 
59
59
  **Firekeep is self-hosted — this package needs a server to talk to.** A person
60
- or team runs its own server, with per-key authentication. Current server images
61
- target `linux/amd64`: use an x86-64 Linux host, or Docker Desktop with amd64
62
- container support on Windows or Mac. There is no hosted endpoint. Server install:
60
+ or team runs its own server, with per-key authentication. Server images target
61
+ `linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
62
+ ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
63
63
  [firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
64
64
 
65
65
  ## Install
@@ -30,9 +30,9 @@ an absent registry is seeded with both (default-on), `firekeep dex remove` is
30
30
  the off-switch, and removals stick across updates.
31
31
 
32
32
  **Firekeep is self-hosted — this package needs a server to talk to.** A person
33
- or team runs its own server, with per-key authentication. Current server images
34
- target `linux/amd64`: use an x86-64 Linux host, or Docker Desktop with amd64
35
- container support on Windows or Mac. There is no hosted endpoint. Server install:
33
+ or team runs its own server, with per-key authentication. Server images target
34
+ `linux/amd64` and, for releases after v1.6.2, `linux/arm64`: use an x86-64 or
35
+ ARM64 Linux host, or Docker Desktop on Windows or Mac. There is no hosted endpoint. Server install:
36
36
  [firekeep.ai/docs.html](https://firekeep.ai/docs.html#server).
37
37
 
38
38
  ## Install
@@ -9,4 +9,4 @@ versions without a verdict (`_check_versions`) and judges staleness against the
9
9
  release manifest instead (`_check_client_version`).
10
10
  """
11
11
 
12
- __version__ = "1.7.2"
12
+ __version__ = "1.8.1"
@@ -46,6 +46,9 @@ def _toml_block(venv_bin: Path) -> str:
46
46
  # TOML literal (single-quoted) string: no escape processing -> Windows backslashes safe.
47
47
  lines.append(f"command = '{cmd}'")
48
48
  lines.append(f"args = [{arglist}]")
49
+ # Codex clears the env of stdio MCP servers; forward what Firekeep
50
+ # Studio sets per run (spec 2026-10-11 §7.2).
51
+ lines.append('env_vars = ["FIREKEEP_SESSION_REPORT_FILE"]')
49
52
  lines.append("")
50
53
  return "\n".join(lines).rstrip()
51
54
 
@@ -65,7 +65,7 @@ _KIT = frozenset({
65
65
  "FIREKEEP_SHIM_DEBUG", "FIREKEEP_NO_JOB_OBJECT",
66
66
  "FIREKEEP_REPORT_CONSENT", "FIREKEEP_REPORT_DIR",
67
67
  "FIREKEEP_FAILURE_REPORT", "FIREKEEP_NO_FAILURE_REPORT",
68
- "FIREKEEP_SESSION_ID", "NEXUS_AGENT_ID",
68
+ "FIREKEEP_SESSION_ID", "FIREKEEP_SESSION_REPORT_FILE", "NEXUS_AGENT_ID",
69
69
  # hook-side knobs the client modules a child imports may consult
70
70
  "FIREKEEP_AGENT_GOAL", "FIREKEEP_BRIEFING", "FIREKEEP_NO_AUTO_UPDATE",
71
71
  "FIREKEEP_NO_AUTO_SYNC", "FIREKEEP_NO_AUTO_INDEX", "FIREKEEP_NO_AUTO_NIGHTSHIFT",
@@ -118,6 +118,9 @@ NOT_FORWARDED: frozenset[str] = frozenset({
118
118
  "FIREKEEP_SIGNING_KEY",
119
119
  # An enrolment code is a credential; only `firekeep join` reads it.
120
120
  "FIREKEEP_JOIN",
121
+ # The bootstrap's fresh-install answer belongs to the install hand-off
122
+ # alone; a child seeing it could count a non-install as a new install.
123
+ "FIREKEEP_INSTALL_REPORT_CONSENT",
121
124
  # Set by the gateway from ~/.firekeep/config for symdex alone; a parent's
122
125
  # copy (stale shell export, another member's key) is never authoritative.
123
126
  "FIREKEEP_CORTEX_URL", "FIREKEEP_CLIENT_API_KEY", "FIREKEEP_CORTEX_CA",
@@ -87,6 +87,44 @@ def cmd_version(args) -> int:
87
87
  return 0
88
88
 
89
89
 
90
+ # --- whoami: identity facts for Studio 0.5 -----------------------------------
91
+
92
+ def cmd_whoami(args) -> int:
93
+ """Identity facts a local client (Firekeep Studio) needs — never the API key.
94
+
95
+ Studio must not read ~/.firekeep/config itself because the file holds the
96
+ key, so this is the one place it learns the agent id (spec 2026-10-11 §7.1).
97
+ """
98
+ cfg = None
99
+ try:
100
+ cfg = resolver.load_config(_config_path())
101
+ except resolver.ConfigMigrationConflict as exc:
102
+ print(f"firekeep: {exc}", file=sys.stderr)
103
+ return 3
104
+ except resolver.ConfigError:
105
+ cfg = None
106
+ agent: str | None = None
107
+ if cfg is not None:
108
+ try:
109
+ agent = resolver.agent_id(cfg)
110
+ except resolver.ConfigError:
111
+ agent = None
112
+ if agent == wizard.PLACEHOLDER_AGENT_ID:
113
+ agent = None # the installer skeleton value is "unconfigured"
114
+ facts = {
115
+ "agent_id": agent,
116
+ "kit_version": __version__,
117
+ "personal_mode": bool(resolver.is_bypassed()),
118
+ "server_configured": bool(cfg is not None and wizard._already_connected(cfg)),
119
+ }
120
+ if getattr(args, "json", False):
121
+ print(json.dumps(facts))
122
+ else:
123
+ for key, value in facts.items():
124
+ print(f"{key}: {value}")
125
+ return 0 if agent else 1
126
+
127
+
90
128
  # --- install: venv, pip, ~/.firekeep bootstrap, adapter render -----------------
91
129
 
92
130
  # `configured = false` is the sentinel that makes "never set up" a REPRESENTABLE
@@ -277,7 +315,18 @@ def _venv_has_pip(venv: Path) -> bool:
277
315
  return True
278
316
 
279
317
 
280
- def _create_venv(venv: Path) -> None:
318
+ def _is_live_venv(home: Path, venv: Path) -> bool:
319
+ """True when the `current` link resolves to `venv` — the install every hook runs."""
320
+ current = home / CURRENT_LINK_NAME
321
+ if not os.path.lexists(current):
322
+ return False
323
+ try:
324
+ return os.path.normcase(os.path.realpath(current)) == os.path.normcase(os.path.realpath(venv))
325
+ except OSError:
326
+ return False
327
+
328
+
329
+ def _create_venv(venv: Path, home: Path) -> None:
281
330
  if _venv_bin(venv).exists():
282
331
  if _venv_has_pip(venv):
283
332
  return
@@ -285,6 +334,16 @@ def _create_venv(venv: Path) -> None:
285
334
  # bootstrap that died between `uv venv` and `uv pip install` leaves exactly this.
286
335
  # Short-circuiting here used to crash the checkout install later in _pip_install;
287
336
  # rebuild in place instead (--clear reprovisions, pip included via ensurepip).
337
+ #
338
+ # Never the LIVE venv, though: a healthy bootstrap venv is pip-less too, and a
339
+ # checkout whose version matches the installed one lands here. On 2026-10-12 the
340
+ # --clear died on executables held by running sessions, leaving Scripts/ without
341
+ # a stdlib, and every hook on the machine failed until the venv was rebuilt.
342
+ if _is_live_venv(home, venv):
343
+ raise RuntimeError(
344
+ f"{venv} is the live install (`current` points at it) and has no pip; "
345
+ "rebuilding it would break every running session. Bump the checkout's "
346
+ "version, or run `firekeep update` to repair the installed release.")
288
347
  _run([sys.executable, "-m", "venv", "--clear", str(venv)])
289
348
  return
290
349
  _run([sys.executable, "-m", "venv", str(venv)])
@@ -402,7 +461,7 @@ def _apply_flags(cfg, args) -> bool:
402
461
  # overwrite an ALREADY-recorded answer — a re-render, an update's
403
462
  # re-exec, or a non-interactive run relaying a pre-set env must not
404
463
  # rewrite a "no" a human already gave.
405
- report.record_consent(cfg, pre == "1")
464
+ report.record_consent(cfg, pre == "1", installs=report.installs_env_answer())
406
465
  touched = True
407
466
  return touched
408
467
 
@@ -555,7 +614,7 @@ def cmd_install(args) -> int:
555
614
  # Checkout install: provision a fresh versioned venv at its FINAL path
556
615
  # (never moved — venvs aren't relocatable) and flip `current` to it below.
557
616
  venv = home / VENVS_DIR_NAME / _kit_version(kit)
558
- _create_venv(venv)
617
+ _create_venv(venv, home)
559
618
  else:
560
619
  venv = Path(sys.executable).resolve().parent.parent
561
620
  python = _venv_python(venv)
@@ -651,8 +710,9 @@ def cmd_install(args) -> int:
651
710
  print(f"firekeep: install failed at '{step}': {exc}", file=sys.stderr)
652
711
  return 1
653
712
 
713
+ report.milestone("installed")
654
714
  print(f"firekeep: installed into {home}")
655
- registered = "firekeep (gateway: memory, sessions, relay, monitoring, code, decisions)"
715
+ registered ="firekeep (gateway: memory, sessions, relay, monitoring, code, decisions)"
656
716
  print(f"firekeep: registered MCP servers: {registered}")
657
717
  print("firekeep: tip — `/personal` (Claude) or `firekeep personal` toggles a private "
658
718
  "session where Firekeep is fully bypassed (nothing logged/recalled); it "
@@ -959,6 +1019,8 @@ def _check_health(cfg) -> list[tuple[str, str, str]]:
959
1019
  else:
960
1020
  for svc, category in failures:
961
1021
  report.emit("connectivity", svc, error=category, cfg=cfg)
1022
+ if not failures:
1023
+ report.milestone("connected", cfg=cfg)
962
1024
  return out
963
1025
 
964
1026
 
@@ -3147,6 +3209,10 @@ def _build_parser() -> argparse.ArgumentParser:
3147
3209
  ver = sub.add_parser("version", help="print client version + skew status")
3148
3210
  ver.set_defaults(func=cmd_version)
3149
3211
 
3212
+ who = sub.add_parser("whoami", help="print this machine's Firekeep identity (never the key)")
3213
+ who.add_argument("--json", action="store_true", help="one line of JSON for programs")
3214
+ who.set_defaults(func=cmd_whoami)
3215
+
3150
3216
  inst = sub.add_parser("install", help="install/refresh the client kit")
3151
3217
  # An unset runtime prepares every shipped adapter. Explicit --runtime (or the
3152
3218
  # bootstrap's FIREKEEP_RUNTIME) remains available for a targeted re-render.
@@ -66,6 +66,15 @@ TOOLSET_PRESETS: dict[str, frozenset[str]] = {
66
66
  "ctx_abandon_session", "ctx_list_sessions", "ctx_resume_session",
67
67
  "ctx_get_shadow",
68
68
  }),
69
+ # Firekeep Studio's Keep panel and per-turn receipts (Studio 0.5, spec
70
+ # 2026-10-11 §7.1). Reads plus knowledge feedback only: Studio never
71
+ # starts, updates or completes a Keep session — the agent owns those.
72
+ "studio": frozenset({
73
+ "memory_recall", "memory_feedback",
74
+ "skill_list", "skill_recall",
75
+ "relay_who_is_online", "relay_read",
76
+ "ctx_list_sessions", "replay_timeline", "corpus_sources",
77
+ }),
69
78
  }
70
79
 
71
80
  # A preset that narrows the tool surface also narrows the handshake text: the
@@ -127,6 +127,8 @@ def run(payload: dict) -> dict:
127
127
  )
128
128
  if isinstance(data, dict) and data.get("rendered"):
129
129
  rendered = data["rendered"]
130
+ # Once per machine; the flush below sends it.
131
+ report.milestone("connected", cfg=cfg, flush_now=False)
130
132
  # Stash the server-minted briefing_id so the bridge shim can inject it
131
133
  # into a ctx_start_session call the agent makes without it — closing the
132
134
  # briefing_id -> session_id A/B join mechanically. (The stale-id clear
@@ -286,6 +286,15 @@ def remember(agent: str, ids: list[str]) -> None:
286
286
  hooklog.log_failure(_HOOK, f"proactive recall dedupe write failed: {e}")
287
287
 
288
288
 
289
+ def _keep_session(cfg) -> str | None:
290
+ """The agent's current Keep session, so the pushed recall is attributed to
291
+ it in replay (Studio 0.5 receipts, spec §7.2). Best-effort: None on any error."""
292
+ try:
293
+ return state.current_session_id(resolver.agent_id(cfg))
294
+ except Exception: # noqa: BLE001
295
+ return None
296
+
297
+
289
298
  def recall(cfg, prompt: str) -> list:
290
299
  """POST the prompt to cortex `/memory/recall` and return its `sources`.
291
300
 
@@ -309,12 +318,15 @@ def recall(cfg, prompt: str) -> list:
309
318
  memory filed under `infrastructure` and friends — 146 of them on the live store,
310
319
  which is the defect `docs/guides/cortex-design-decisions.md` records.
311
320
 
321
+ It carries the agent's current Keep session (`X-Session-Id`) so the recall shows
322
+ in that session's replay timeline.
323
+
312
324
  Returns [] for a DEGRADED response (vector search down, results graph-only).
313
325
  Same call as Bridge makes: the floor below is calibrated on cosine, and a
314
326
  graph-only set is a different scale, so pushing it would be pushing noise the
315
327
  threshold cannot judge.
316
328
  """
317
- ep = resolver.resolve("cortex", cfg=cfg)
329
+ ep = resolver.resolve("cortex", cfg=cfg, session_id=_keep_session(cfg))
318
330
  body = {
319
331
  "task": prompt[:MAX_TASK_CHARS],
320
332
  "top_k": TOP_K,
@@ -72,6 +72,15 @@ _STAGES_BY_KIND = {
72
72
  "runtime": RUNTIME_STAGES,
73
73
  }
74
74
 
75
+ # Install milestones: the success half of the funnel, so downloads can be read
76
+ # against installs that worked. NOT a failure kind — its own consent key
77
+ # ([report] installs), its own once-per-machine marker, no `error` field, and
78
+ # the collector logs it apart from failures (never mailed, never pulled into
79
+ # Sentinel). installed = `firekeep install` completed; connected = the first
80
+ # time this machine reached its Keep (doctor all-green or a served briefing).
81
+ MILESTONE_KIND = "milestone"
82
+ MILESTONE_STAGES = ("installed", "connected")
83
+
75
84
  _ERRNO_MAP = {
76
85
  _errno.EACCES: "permission-denied",
77
86
  _errno.EPERM: "permission-denied",
@@ -184,15 +193,43 @@ def build_event(kind, stage, *, error=None, exc=None, exit_code=None,
184
193
  return None
185
194
 
186
195
 
196
+ def build_milestone(stage) -> dict | None:
197
+ """Validated milestone event, or None. Same closed fields as a failure
198
+ minus `error` and every tagged-union extra."""
199
+ try:
200
+ if stage not in MILESTONE_STAGES:
201
+ return None
202
+ os_family = detect_os()
203
+ if os_family not in OS_FAMILIES:
204
+ return None
205
+ from firekeep_client import __version__
206
+ return {
207
+ "id": uuid.uuid4().hex,
208
+ "kind": MILESTONE_KIND,
209
+ "stage": stage,
210
+ "os": os_family,
211
+ "arch": detect_arch(),
212
+ "client": __version__,
213
+ "py": py_bucket(),
214
+ }
215
+ except Exception: # noqa: BLE001
216
+ return None
217
+
218
+
187
219
  _FALSEY = ("", "0", "false", "no", "off")
188
220
 
221
+ # One question, two recorded answers ([report] failures + [report] installs).
222
+ # Machines enrolled under the earlier failures-only wording keep `installs`
223
+ # absent and so never send a milestone: their consent is not widened after
224
+ # the fact. The bootstraps (install.sh / install.ps1) carry the same scope.
189
225
  CONSENT_PROMPT = (
190
- "Send anonymous failure reports to firekeep.ai? When an install step fails, a\n"
191
- "connection to your own Keep fails, or a Firekeep background task errors,\n"
192
- "Firekeep sends category codes only — what failed, the error class, OS family,\n"
193
- "versions. Never paths, messages, addresses, or any persistent device, account\n"
194
- "or session identifier. Ongoing until you turn it off\n"
195
- "([report] failures = false). [Y/n] "
226
+ "Send anonymous reports to firekeep.ai? Once when this install succeeds and\n"
227
+ "first reaches your Keep, and whenever an install step fails, a connection to\n"
228
+ "your own Keep fails, or a Firekeep background task errors, Firekeep sends\n"
229
+ "category codes only — what happened, the error class, OS family, versions.\n"
230
+ "Never paths, messages, addresses, or any persistent device, account or\n"
231
+ "session identifier. Ongoing until you turn it off\n"
232
+ "([report] failures = false, [report] installs = false). [Y/n] "
196
233
  )
197
234
 
198
235
 
@@ -216,6 +253,23 @@ def is_enabled(cfg=None) -> bool:
216
253
  return False
217
254
 
218
255
 
256
+ def milestones_enabled(cfg=None) -> bool:
257
+ """Consent gate for install milestones. Stricter than is_enabled: only a
258
+ recorded `[report] installs = true` counts — there is deliberately no env
259
+ opt-in, so CI runs that set FIREKEEP_FAILURE_REPORT=1 never count as
260
+ installs. The failure opt-out and personal mode silence it too."""
261
+ try:
262
+ if os.environ.get("FIREKEEP_NO_FAILURE_REPORT", "").strip().lower() not in _FALSEY:
263
+ return False
264
+ if resolver.is_bypassed():
265
+ return False
266
+ if cfg is None:
267
+ cfg = resolver.load_config()
268
+ return cfg.get("report", "installs", fallback="").strip().lower() == "true"
269
+ except Exception: # noqa: BLE001 — any doubt means OFF (fail closed)
270
+ return False
271
+
272
+
219
273
  def has_answer(cfg) -> bool:
220
274
  try:
221
275
  return cfg.get("report", "failures", fallback="").strip() != ""
@@ -223,15 +277,38 @@ def has_answer(cfg) -> bool:
223
277
  return False
224
278
 
225
279
 
226
- def record_consent(cfg, value: bool) -> None:
280
+ def record_consent(cfg, value: bool, *, installs: bool | None = None) -> None:
281
+ """Record the failures answer, and the installs answer only when the
282
+ human was shown wording that covers milestones (installs is not None)."""
227
283
  try:
228
284
  if not cfg.has_section("report"):
229
285
  cfg.add_section("report")
230
286
  cfg.set("report", "failures", "true" if value else "false")
287
+ if installs is not None:
288
+ cfg.set("report", "installs", "true" if installs else "false")
231
289
  except Exception: # noqa: BLE001
232
290
  pass
233
291
 
234
292
 
293
+ # Set when THIS process recorded an answer from a human at the prompt. With
294
+ # the bootstrap's relayed answer it defines a fresh install: the prompt only
295
+ # ever appears on a machine with no recorded answer, so an update, a re-render
296
+ # or a later opt-in never counts as a new install.
297
+ _ANSWERED_HERE = False
298
+
299
+
300
+ def _fresh_install_consented() -> bool:
301
+ return installs_env_answer() is True or _ANSWERED_HERE
302
+
303
+
304
+ def installs_env_answer() -> bool | None:
305
+ """The bootstrap relays its prompt's answer in FIREKEEP_INSTALL_REPORT_CONSENT,
306
+ set ONLY when a human answered the milestone-covering wording — never by
307
+ FIREKEEP_FAILURE_REPORT or an update re-exec. None = not shown that prompt."""
308
+ pre = os.environ.get("FIREKEEP_INSTALL_REPORT_CONSENT", "").strip()
309
+ return (pre == "1") if pre in ("0", "1") else None
310
+
311
+
235
312
  def ask_consent(cfg) -> bool:
236
313
  """Ask once; record only a real answer. EOF and Ctrl-C record NOTHING —
237
314
  deliberately not wizard.console_ask, whose EOF-takes-the-default would
@@ -242,7 +319,7 @@ def ask_consent(cfg) -> bool:
242
319
  return False
243
320
  pre = os.environ.get("FIREKEEP_REPORT_CONSENT", "").strip()
244
321
  if pre in ("0", "1"):
245
- record_consent(cfg, pre == "1")
322
+ record_consent(cfg, pre == "1", installs=installs_env_answer())
246
323
  return True
247
324
  if not sys.stdin.isatty():
248
325
  return False
@@ -256,7 +333,10 @@ def ask_consent(cfg) -> bool:
256
333
  except (EOFError, KeyboardInterrupt):
257
334
  print()
258
335
  return False
259
- record_consent(cfg, answer in ("", "y", "yes"))
336
+ yes = answer in ("", "y", "yes")
337
+ record_consent(cfg, yes, installs=yes)
338
+ global _ANSWERED_HERE
339
+ _ANSWERED_HERE = yes
260
340
  return True
261
341
  except Exception: # noqa: BLE001
262
342
  return False
@@ -373,6 +453,56 @@ def emit(kind, stage, *, error=None, exc=None, exit_code=None,
373
453
  pass
374
454
 
375
455
 
456
+ def _milestones_path() -> Path:
457
+ return _report_dir() / "report-milestones.json"
458
+
459
+
460
+ def _claim_milestone(stage: str) -> bool:
461
+ """Mark `stage` sent for this machine; True only for the caller that
462
+ newly marked it. The marker is written BEFORE the event is spooled and a
463
+ failed write means no event: a milestone may be lost, never repeated —
464
+ repeats would inflate exactly the count this exists to make honest.
465
+
466
+ The funnel is nested: `installed` only on a fresh install whose prompt was
467
+ answered yes in this run, `connected` only on a machine that already sent
468
+ `installed` — so an existing machine opting in later counts as neither."""
469
+ try:
470
+ path = _milestones_path()
471
+ seen = {}
472
+ if path.exists():
473
+ seen = json.loads(path.read_text(encoding="utf-8"))
474
+ if not isinstance(seen, dict):
475
+ return False # unreadable marker: assume sent
476
+ if stage in seen:
477
+ return False
478
+ if stage == "installed" and not _fresh_install_consented():
479
+ return False
480
+ if stage == "connected" and "installed" not in seen:
481
+ return False
482
+ seen[stage] = int(time.time())
483
+ path.parent.mkdir(parents=True, exist_ok=True)
484
+ path.write_text(json.dumps(seen), encoding="utf-8")
485
+ return True
486
+ except Exception: # noqa: BLE001
487
+ return False
488
+
489
+
490
+ def milestone(stage, *, cfg=None, flush_now: bool = True) -> None:
491
+ """Record an install milestone once per machine. Never raises; same spool
492
+ and wire as emit(), so an offline machine sends it on a later flush."""
493
+ try:
494
+ if not milestones_enabled(cfg):
495
+ return
496
+ event = build_milestone(stage)
497
+ if event is None or not _claim_milestone(stage):
498
+ return
499
+ _append_spool(event)
500
+ if flush_now:
501
+ flush(cfg=cfg)
502
+ except Exception: # noqa: BLE001
503
+ pass
504
+
505
+
376
506
  def _read_events(path: Path) -> list[dict]:
377
507
  out = []
378
508
  try:
@@ -443,7 +573,8 @@ def flush(cfg=None, timeout=None) -> None:
443
573
  if timeout is None:
444
574
  timeout = FLUSH_TIMEOUT
445
575
  try:
446
- if not is_enabled(cfg):
576
+ failures_on, milestones_on = is_enabled(cfg), milestones_enabled(cfg)
577
+ if not (failures_on or milestones_on):
447
578
  return
448
579
  claims = _adoptable_claims()
449
580
  spool = _spool_path()
@@ -457,6 +588,11 @@ def flush(cfg=None, timeout=None) -> None:
457
588
  claim_path.unlink()
458
589
  except OSError:
459
590
  pass
591
+ # Each event travels only under its own consent: a spooled failure
592
+ # from before `failures = false` is dropped, not sent under the
593
+ # installs answer (and vice versa).
594
+ events = [e for e in events
595
+ if (milestones_on if e.get("kind") == MILESTONE_KIND else failures_on)]
460
596
  if not events:
461
597
  continue
462
598
  try:
@@ -159,8 +159,9 @@ class _StashSessionAuth(httpx.Auth):
159
159
  # Tap-wired: what THIS process observed, or nothing.
160
160
  sid = self._tap._session_id
161
161
  else:
162
- stash = state.read_session_stash(self._agent)
163
- sid = (stash or {}).get("session_id")
162
+ # Fresh stash first; under Studio, the report file survives
163
+ # the stash clear a resumed runtime's session_start performs.
164
+ sid = state.current_session_id(self._agent)
164
165
  if sid:
165
166
  request.headers["X-Session-Id"] = sid
166
167
  except Exception:
@@ -515,11 +516,15 @@ class _BridgeSessionTap:
515
516
  return item
516
517
  name = params.get("name")
517
518
  rid = getattr(root, "id", None)
518
- if rid is not None and (
519
- name in self._CAPTURE_TOOLS
520
- or name in self._END_TOOLS
521
- ):
519
+ if rid is not None and name in self._CAPTURE_TOOLS:
522
520
  self._pending[rid] = name
521
+ elif rid is not None and name in self._END_TOOLS:
522
+ # Remember WHICH session the call targets: an explicit id from
523
+ # the agent wins, else this process's captured id.
524
+ raw_args = params.get("arguments")
525
+ explicit = raw_args.get("session_id") if isinstance(raw_args, dict) else None
526
+ target = explicit if isinstance(explicit, str) and explicit else self._session_id
527
+ self._pending[rid] = (name, target)
523
528
  if name in self._INJECT_TOOLS:
524
529
  args = params.get("arguments")
525
530
  if isinstance(args, dict):
@@ -546,8 +551,16 @@ class _BridgeSessionTap:
546
551
  rid = getattr(root, "id", None)
547
552
  if rid is None or rid not in self._pending:
548
553
  return item
549
- name = self._pending.pop(rid)
554
+ entry = self._pending.pop(rid)
555
+ target = None
556
+ if isinstance(entry, tuple):
557
+ name, target = entry
558
+ else:
559
+ name = entry
550
560
  if name in self._END_TOOLS:
561
+ # Tombstone the session the call targeted before clearing.
562
+ if target:
563
+ state.end_session_report(target)
551
564
  self._session_id = None
552
565
  state.clear_session_stash(self._agent)
553
566
  return item
@@ -557,6 +570,9 @@ class _BridgeSessionTap:
557
570
  # Keep writing the agent-keyed disk stash: hook cores in other
558
571
  # processes still read it, and it is the restart fallback.
559
572
  state.write_session_stash(self._agent, session_id=sid)
573
+ # Studio 0.5: tell the launching Studio session which Keep
574
+ # session this agent is in (no-op unless the env var is set).
575
+ state.append_session_report(sid)
560
576
  except Exception:
561
577
  pass
562
578
  return item
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import json
8
8
  import os
9
+ import re
9
10
  import subprocess
10
11
  import sys
11
12
  import time
@@ -448,6 +449,90 @@ def clear_session_stash(agent_id: str) -> None:
448
449
  pass
449
450
 
450
451
 
452
+ # Firekeep Studio 0.5 (spec 2026-10-11 §7.2): Studio launches each agent with
453
+ # this variable naming a per-Studio-session file. The bridge tap appends every
454
+ # Keep session id it captures, so Studio can build per-turn receipts from the
455
+ # sessions the AGENT started — Studio never starts one itself.
456
+ SESSION_REPORT_ENV = "FIREKEEP_SESSION_REPORT_FILE"
457
+ # Ids travel in an HTTP header (X-Session-Id): no whitespace, no newline, ever.
458
+ _SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
459
+
460
+
461
+ def _report_path() -> str | None:
462
+ raw = os.environ.get(SESSION_REPORT_ENV, "").strip()
463
+ return raw or None
464
+
465
+
466
+ def append_session_report(session_id: str) -> None:
467
+ """Append a captured Keep session id to $FIREKEEP_SESSION_REPORT_FILE.
468
+ No-op when the variable is unset or the id is not header-safe. Never raises."""
469
+ try:
470
+ path = _report_path()
471
+ if not path or not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id):
472
+ return
473
+ with open(path, "a", encoding="utf-8", newline="\n") as fh:
474
+ fh.write(session_id + "\n")
475
+ except Exception:
476
+ pass
477
+
478
+
479
+ def end_session_report(session_id: str) -> None:
480
+ """Mark a session as ended in $FIREKEEP_SESSION_REPORT_FILE by appending
481
+ '#ended <id>'. No-op when the variable is unset or the id is not header-safe.
482
+ Never raises."""
483
+ try:
484
+ path = _report_path()
485
+ if not path or not isinstance(session_id, str) or not _SESSION_ID_RE.fullmatch(session_id):
486
+ return
487
+ with open(path, "a", encoding="utf-8", newline="\n") as fh:
488
+ fh.write(f"#ended {session_id}\n")
489
+ except Exception:
490
+ pass
491
+
492
+
493
+ def reported_session_id() -> str | None:
494
+ """The last active (not ended) header-safe id in $FIREKEEP_SESSION_REPORT_FILE.
495
+ A line '#ended <id>' marks an id as ended; scanning in order, the last active
496
+ id is returned, or None. A file whose mtime is older than the session stash
497
+ TTL is ignored (a stale file must not attribute a new day's work to a dead
498
+ session). Handles non-UTF-8 bytes gracefully."""
499
+ try:
500
+ path = _report_path()
501
+ if not path:
502
+ return None
503
+ if time.time() - os.path.getmtime(path) > _session_stash_ttl_seconds():
504
+ return None
505
+ last = None
506
+ with open(path, encoding="utf-8", errors="replace") as fh:
507
+ for line in fh:
508
+ line = line.strip()
509
+ if line.startswith("#ended "):
510
+ # Extract the id after "#ended "
511
+ ended_id = line[7:] # len("#ended ") == 7
512
+ # Deliberately fail-closed: a tombstone clears only the
513
+ # most recent id, so "K1, K2, #ended K2" yields None even
514
+ # if K1 is still live. Tracking a set of ended ids would
515
+ # revive sessions that ended without writing a tombstone.
516
+ if ended_id == last:
517
+ last = None
518
+ elif _SESSION_ID_RE.fullmatch(line):
519
+ last = line
520
+ return last
521
+ except Exception:
522
+ return None
523
+
524
+
525
+ def current_session_id(agent_id: str) -> str | None:
526
+ """The Keep session this agent is working in: the fresh stash, else the
527
+ Studio report file (which survives the stash clear a resumed runtime's
528
+ session_start performs). Never raises."""
529
+ try:
530
+ stash = read_session_stash(agent_id) or {}
531
+ return stash.get("session_id") or reported_session_id()
532
+ except Exception:
533
+ return None
534
+
535
+
451
536
  # --- runbook bundle store (Enforced Runbooks Phase B) ------------------------
452
537
  #
453
538
  # Spec: docs/superpowers/specs/2026-08-15-enforced-runbooks-design.md ("Bundle").