gobby 0.2.5__py3-none-any.whl

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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,817 @@
1
+ """Local session storage manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import json
7
+ import logging
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from typing import Any
12
+
13
+ from gobby.storage.database import DatabaseProtocol
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Session:
20
+ """Session data model."""
21
+
22
+ id: str
23
+ external_id: str
24
+ machine_id: str
25
+ source: str
26
+ project_id: str # Required - sessions must belong to a project
27
+ title: str | None
28
+ status: str
29
+ jsonl_path: str | None
30
+ summary_path: str | None
31
+ summary_markdown: str | None
32
+ compact_markdown: str | None # Handoff context for compaction
33
+ git_branch: str | None
34
+ parent_session_id: str | None
35
+ created_at: str
36
+ updated_at: str
37
+ agent_depth: int = 0 # 0 = human-initiated, 1+ = agent-spawned
38
+ spawned_by_agent_id: str | None = None # ID of agent that spawned this session
39
+ # Terminal pickup metadata fields
40
+ workflow_name: str | None = None # Workflow to activate on terminal pickup
41
+ agent_run_id: str | None = None # Link back to agent run record
42
+ context_injected: bool = False # Whether context was injected into prompt
43
+ original_prompt: str | None = None # Original prompt for terminal mode
44
+ # Usage tracking fields
45
+ usage_input_tokens: int = 0
46
+ usage_output_tokens: int = 0
47
+ usage_cache_creation_tokens: int = 0
48
+ usage_cache_read_tokens: int = 0
49
+ usage_total_cost_usd: float = 0.0
50
+ # Terminal context (JSON blob with tty, parent_pid, term_session_id, etc.)
51
+ terminal_context: dict[str, Any] | None = None
52
+ # Global sequence number
53
+ seq_num: int | None = None
54
+
55
+ @classmethod
56
+ def from_row(cls, row: Any) -> Session:
57
+ """Create Session from database row."""
58
+ return cls(
59
+ id=row["id"],
60
+ external_id=row["external_id"],
61
+ machine_id=row["machine_id"],
62
+ source=row["source"],
63
+ project_id=row["project_id"],
64
+ title=row["title"],
65
+ status=row["status"],
66
+ jsonl_path=row["jsonl_path"],
67
+ summary_path=row["summary_path"],
68
+ summary_markdown=row["summary_markdown"],
69
+ compact_markdown=row["compact_markdown"],
70
+ git_branch=row["git_branch"],
71
+ parent_session_id=row["parent_session_id"],
72
+ created_at=row["created_at"],
73
+ updated_at=row["updated_at"],
74
+ agent_depth=row["agent_depth"] or 0,
75
+ spawned_by_agent_id=row["spawned_by_agent_id"],
76
+ workflow_name=row["workflow_name"],
77
+ agent_run_id=row["agent_run_id"],
78
+ context_injected=bool(row["context_injected"]),
79
+ original_prompt=row["original_prompt"],
80
+ usage_input_tokens=row["usage_input_tokens"] or 0,
81
+ usage_output_tokens=row["usage_output_tokens"] or 0,
82
+ usage_cache_creation_tokens=row["usage_cache_creation_tokens"] or 0,
83
+ usage_cache_read_tokens=row["usage_cache_read_tokens"] or 0,
84
+ usage_total_cost_usd=row["usage_total_cost_usd"] or 0.0,
85
+ terminal_context=cls._parse_terminal_context(row["terminal_context"]),
86
+ seq_num=row["seq_num"] if "seq_num" in row.keys() else None,
87
+ )
88
+
89
+ @classmethod
90
+ def _parse_terminal_context(cls, raw: str | None) -> dict[str, Any] | None:
91
+ """Parse terminal_context JSON, returning None on malformed data.
92
+
93
+ Args:
94
+ raw: Raw JSON string or None
95
+
96
+ Returns:
97
+ Parsed dict or None if parsing fails or input is None
98
+ """
99
+ if not raw:
100
+ return None
101
+ try:
102
+ result: dict[str, Any] = json.loads(raw)
103
+ return result
104
+ except json.JSONDecodeError:
105
+ logger.warning("Failed to parse terminal_context JSON, returning None")
106
+ return None
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ """Convert to dictionary."""
110
+ return {
111
+ "ref": f"#{self.seq_num}" if self.seq_num else self.id[:8],
112
+ "external_id": self.external_id,
113
+ "machine_id": self.machine_id,
114
+ "source": self.source,
115
+ "project_id": self.project_id,
116
+ "title": self.title,
117
+ "status": self.status,
118
+ "jsonl_path": self.jsonl_path,
119
+ "summary_path": self.summary_path,
120
+ "summary_markdown": self.summary_markdown,
121
+ "compact_markdown": self.compact_markdown,
122
+ "git_branch": self.git_branch,
123
+ "parent_session_id": self.parent_session_id,
124
+ "agent_depth": self.agent_depth,
125
+ "spawned_by_agent_id": self.spawned_by_agent_id,
126
+ "workflow_name": self.workflow_name,
127
+ "agent_run_id": self.agent_run_id,
128
+ "context_injected": self.context_injected,
129
+ "original_prompt": self.original_prompt,
130
+ "usage_input_tokens": self.usage_input_tokens,
131
+ "usage_output_tokens": self.usage_output_tokens,
132
+ "usage_cache_creation_tokens": self.usage_cache_creation_tokens,
133
+ "usage_cache_read_tokens": self.usage_cache_read_tokens,
134
+ "usage_total_cost_usd": self.usage_total_cost_usd,
135
+ "terminal_context": self.terminal_context,
136
+ "created_at": self.created_at,
137
+ "updated_at": self.updated_at,
138
+ "seq_num": self.seq_num,
139
+ "id": self.id, # UUID at end for backwards compat
140
+ }
141
+
142
+
143
+ class LocalSessionManager:
144
+ """Manager for local session storage."""
145
+
146
+ def __init__(self, db: DatabaseProtocol):
147
+ """Initialize with database connection."""
148
+ self.db = db
149
+
150
+ def register(
151
+ self,
152
+ external_id: str,
153
+ machine_id: str,
154
+ source: str,
155
+ project_id: str,
156
+ title: str | None = None,
157
+ jsonl_path: str | None = None,
158
+ git_branch: str | None = None,
159
+ parent_session_id: str | None = None,
160
+ agent_depth: int = 0,
161
+ spawned_by_agent_id: str | None = None,
162
+ terminal_context: dict[str, Any] | None = None,
163
+ ) -> Session:
164
+ """
165
+ Register a new session or return existing one.
166
+
167
+ Looks up by (external_id, machine_id, project_id, source) to find if this
168
+ exact session already exists (e.g., daemon restarted mid-session). If found,
169
+ returns the existing session. Otherwise creates a new one.
170
+
171
+ Args:
172
+ external_id: External session identifier (e.g., Claude Code's session ID)
173
+ machine_id: Machine identifier
174
+ source: CLI source (claude_code, codex, gemini)
175
+ project_id: Project ID (required - sessions must belong to a project)
176
+ title: Optional session title
177
+ jsonl_path: Path to transcript file
178
+ git_branch: Git branch name
179
+ parent_session_id: Parent session for handoff
180
+ agent_depth: Nesting depth (0 = human-initiated, 1+ = agent-spawned)
181
+ spawned_by_agent_id: ID of the agent that spawned this session
182
+
183
+ Returns:
184
+ Session instance
185
+ """
186
+ now = datetime.now(UTC).isoformat()
187
+
188
+ # Check if this exact session already exists (daemon restart case)
189
+ existing = self.find_by_external_id(external_id, machine_id, project_id, source)
190
+ if existing:
191
+ # Session exists - update metadata and return it
192
+ self.db.execute(
193
+ """
194
+ UPDATE sessions SET
195
+ title = COALESCE(?, title),
196
+ jsonl_path = COALESCE(?, jsonl_path),
197
+ git_branch = COALESCE(?, git_branch),
198
+ parent_session_id = COALESCE(?, parent_session_id),
199
+ status = 'active',
200
+ updated_at = ?
201
+ WHERE id = ?
202
+ """,
203
+ (
204
+ title,
205
+ jsonl_path,
206
+ git_branch,
207
+ parent_session_id,
208
+ now,
209
+ existing.id,
210
+ ),
211
+ )
212
+ logger.debug(f"Reusing existing session {existing.id} for external_id={external_id}")
213
+ session = self.get(existing.id)
214
+ if session is None:
215
+ raise RuntimeError(f"Session {existing.id} disappeared during update")
216
+ return session
217
+
218
+ # New session - create it
219
+ session_id = str(uuid.uuid4())
220
+
221
+ # Retry loop for seq_num assignment
222
+ max_retries = 3
223
+ for attempt in range(max_retries):
224
+ try:
225
+ # Get next seq_num (global)
226
+ max_seq_row = self.db.fetchone("SELECT MAX(seq_num) as max_seq FROM sessions")
227
+ next_seq_num = ((max_seq_row["max_seq"] if max_seq_row else None) or 0) + 1
228
+
229
+ self.db.execute(
230
+ """
231
+ INSERT INTO sessions (
232
+ id, external_id, machine_id, source, project_id, title,
233
+ jsonl_path, git_branch, parent_session_id,
234
+ agent_depth, spawned_by_agent_id, terminal_context,
235
+ status, created_at, updated_at, seq_num
236
+ )
237
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
238
+ """,
239
+ (
240
+ session_id,
241
+ external_id,
242
+ machine_id,
243
+ source,
244
+ project_id,
245
+ title,
246
+ jsonl_path,
247
+ git_branch,
248
+ parent_session_id,
249
+ agent_depth,
250
+ spawned_by_agent_id,
251
+ json.dumps(terminal_context) if terminal_context else None,
252
+ now,
253
+ now,
254
+ next_seq_num,
255
+ ),
256
+ )
257
+ break
258
+ except Exception as e:
259
+ # Check for unique constraint violation on seq_num
260
+ if (
261
+ "UNIQUE constraint failed: sessions.seq_num" in str(e)
262
+ and attempt < max_retries - 1
263
+ ):
264
+ logger.warning(f"Seq_num collision ({next_seq_num}), retrying...")
265
+ continue
266
+ raise
267
+
268
+ logger.debug(f"Created new session {session_id} for external_id={external_id}")
269
+
270
+ session = self.get(session_id)
271
+ if session is None:
272
+ raise RuntimeError(f"Session {session_id} not found after creation")
273
+ return session
274
+
275
+ def get(self, session_id: str) -> Session | None:
276
+ """Get session by ID."""
277
+ row = self.db.fetchone("SELECT * FROM sessions WHERE id = ?", (session_id,))
278
+ return Session.from_row(row) if row else None
279
+
280
+ def resolve_session_reference(self, ref: str) -> str:
281
+ """
282
+ Resolve a session reference to a UUID.
283
+
284
+ Supports:
285
+ - #N: Global Sequence Number (e.g., #1)
286
+ - N: Integer string treated as #N (e.g., "1")
287
+ - UUID: Full UUID
288
+ - Prefix: UUID prefix (must be unambiguous)
289
+
290
+ Args:
291
+ ref: Session reference string
292
+
293
+ Returns:
294
+ Resolved Session UUID
295
+
296
+ Raises:
297
+ ValueError: If not found or ambiguous
298
+ """
299
+ if not ref:
300
+ raise ValueError("Empty session reference")
301
+
302
+ # #N or N format: seq_num lookup
303
+ seq_num_ref = ref
304
+ if ref.startswith("#"):
305
+ seq_num_ref = ref[1:]
306
+
307
+ if seq_num_ref.isdigit():
308
+ seq_num = int(seq_num_ref)
309
+ row = self.db.fetchone("SELECT id FROM sessions WHERE seq_num = ?", (seq_num,))
310
+ if not row:
311
+ raise ValueError(f"Session #{seq_num} not found")
312
+ return str(row["id"])
313
+
314
+ # Full UUID check
315
+ try:
316
+ uuid_obj = uuid.UUID(ref)
317
+ # Verify the session exists in the database
318
+ row = self.db.fetchone("SELECT id FROM sessions WHERE id = ?", (str(uuid_obj),))
319
+ if not row:
320
+ raise ValueError(f"Session '{ref}' not found")
321
+ return str(uuid_obj)
322
+ except ValueError:
323
+ pass # Not a valid UUID, try prefix
324
+
325
+ # Prefix matching
326
+ rows = self.db.fetchall("SELECT id FROM sessions WHERE id LIKE ? LIMIT 5", (f"{ref}%",))
327
+ if not rows:
328
+ raise ValueError(f"Session '{ref}' not found")
329
+ if len(rows) > 1:
330
+ matches = [str(r["id"]) for r in rows]
331
+ raise ValueError(f"Ambiguous session '{ref}' matches: {', '.join(matches[:3])}...")
332
+
333
+ return str(rows[0]["id"])
334
+
335
+ def find_by_external_id(
336
+ self,
337
+ external_id: str,
338
+ machine_id: str,
339
+ project_id: str,
340
+ source: str,
341
+ ) -> Session | None:
342
+ """
343
+ Find session by external_id, machine_id, project_id, and source.
344
+
345
+ This is the primary lookup for reconnecting to an existing session
346
+ after daemon restart. The external_id (e.g., Claude Code's session ID)
347
+ is stable within a session.
348
+
349
+ Args:
350
+ external_id: External session identifier
351
+ machine_id: Machine identifier
352
+ project_id: Project identifier
353
+ source: CLI source (claude, gemini, codex)
354
+
355
+ Returns:
356
+ Session if found, None otherwise.
357
+ """
358
+ row = self.db.fetchone(
359
+ """
360
+ SELECT * FROM sessions
361
+ WHERE external_id = ? AND machine_id = ? AND project_id = ? AND source = ?
362
+ """,
363
+ (external_id, machine_id, project_id, source),
364
+ )
365
+ return Session.from_row(row) if row else None
366
+
367
+ def find_parent(
368
+ self,
369
+ machine_id: str,
370
+ project_id: str,
371
+ source: str | None = None,
372
+ status: str = "handoff_ready",
373
+ ) -> Session | None:
374
+ """
375
+ Find most recent parent session with specific status.
376
+
377
+ Args:
378
+ machine_id: Machine identifier
379
+ project_id: Project identifier
380
+ source: Optional source identifier to filter by
381
+ status: Status to filter by (default: handoff_ready)
382
+
383
+ Returns:
384
+ Session object or None
385
+ """
386
+ query = "SELECT * FROM sessions WHERE machine_id = ? AND status = ? AND project_id = ?"
387
+ params: list[Any] = [machine_id, status, project_id]
388
+
389
+ if source:
390
+ query += " AND source = ?"
391
+ params.append(source)
392
+
393
+ query += " ORDER BY updated_at DESC LIMIT 1"
394
+
395
+ row = self.db.fetchone(query, tuple(params))
396
+ return Session.from_row(row) if row else None
397
+
398
+ def find_children(self, parent_session_id: str) -> list[Session]:
399
+ """
400
+ Find all child sessions of a parent.
401
+
402
+ Args:
403
+ parent_session_id: The parent session ID.
404
+
405
+ Returns:
406
+ List of child Session objects.
407
+ """
408
+ rows = self.db.fetchall(
409
+ """
410
+ SELECT * FROM sessions
411
+ WHERE parent_session_id = ?
412
+ ORDER BY created_at ASC
413
+ """,
414
+ (parent_session_id,),
415
+ )
416
+ return [Session.from_row(row) for row in rows]
417
+
418
+ def update_status(self, session_id: str, status: str) -> Session | None:
419
+ """Update session status."""
420
+ now = datetime.now(UTC).isoformat()
421
+ self.db.execute(
422
+ "UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?",
423
+ (status, now, session_id),
424
+ )
425
+ return self.get(session_id)
426
+
427
+ def update_title(self, session_id: str, title: str) -> Session | None:
428
+ """Update session title."""
429
+ now = datetime.now(UTC).isoformat()
430
+ self.db.execute(
431
+ "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
432
+ (title, now, session_id),
433
+ )
434
+ return self.get(session_id)
435
+
436
+ def update_summary(
437
+ self,
438
+ session_id: str,
439
+ summary_path: str | None = None,
440
+ summary_markdown: str | None = None,
441
+ ) -> Session | None:
442
+ """Update session summary."""
443
+ now = datetime.now(UTC).isoformat()
444
+ self.db.execute(
445
+ """
446
+ UPDATE sessions
447
+ SET summary_path = COALESCE(?, summary_path),
448
+ summary_markdown = COALESCE(?, summary_markdown),
449
+ updated_at = ?
450
+ WHERE id = ?
451
+ """,
452
+ (summary_path, summary_markdown, now, session_id),
453
+ )
454
+ return self.get(session_id)
455
+
456
+ def update_compact_markdown(self, session_id: str, compact_markdown: str) -> Session | None:
457
+ """Update session compact handoff markdown."""
458
+ now = datetime.now(UTC).isoformat()
459
+ self.db.execute(
460
+ """
461
+ UPDATE sessions
462
+ SET compact_markdown = ?,
463
+ updated_at = ?
464
+ WHERE id = ?
465
+ """,
466
+ (compact_markdown, now, session_id),
467
+ )
468
+ return self.get(session_id)
469
+
470
+ def update_parent_session_id(self, session_id: str, parent_session_id: str) -> Session | None:
471
+ """Update parent session ID."""
472
+ now = datetime.now(UTC).isoformat()
473
+ self.db.execute(
474
+ "UPDATE sessions SET parent_session_id = ?, updated_at = ? WHERE id = ?",
475
+ (parent_session_id, now, session_id),
476
+ )
477
+ return self.get(session_id)
478
+
479
+ def update(
480
+ self,
481
+ session_id: str,
482
+ *,
483
+ external_id: str | None = None,
484
+ jsonl_path: str | None = None,
485
+ status: str | None = None,
486
+ title: str | None = None,
487
+ git_branch: str | None = None,
488
+ ) -> Session | None:
489
+ """
490
+ Update multiple session fields at once.
491
+
492
+ Args:
493
+ session_id: Session ID to update
494
+ external_id: New external ID (optional)
495
+ jsonl_path: New transcript path (optional)
496
+ status: New status (optional)
497
+ title: New title (optional)
498
+ git_branch: New git branch (optional)
499
+
500
+ Returns:
501
+ Updated Session or None if not found
502
+ """
503
+ values: dict[str, Any] = {}
504
+
505
+ if external_id is not None:
506
+ values["external_id"] = external_id
507
+ if jsonl_path is not None:
508
+ values["jsonl_path"] = jsonl_path
509
+ if status is not None:
510
+ values["status"] = status
511
+ if title is not None:
512
+ values["title"] = title
513
+ if git_branch is not None:
514
+ values["git_branch"] = git_branch
515
+
516
+ if not values:
517
+ return self.get(session_id)
518
+
519
+ values["updated_at"] = datetime.now(UTC).isoformat()
520
+
521
+ self.db.safe_update("sessions", values, "id = ?", (session_id,))
522
+ return self.get(session_id)
523
+
524
+ def list(
525
+ self,
526
+ project_id: str | None = None,
527
+ status: str | None = None,
528
+ source: str | None = None,
529
+ limit: int = 100,
530
+ ) -> list[Session]:
531
+ """
532
+ List sessions with optional filters.
533
+
534
+ Args:
535
+ project_id: Filter by project
536
+ status: Filter by status
537
+ source: Filter by CLI source
538
+ limit: Maximum number of results
539
+
540
+ Returns:
541
+ List of Session instances
542
+ """
543
+ conditions = []
544
+ params: list[Any] = []
545
+
546
+ if project_id:
547
+ conditions.append("project_id = ?")
548
+ params.append(project_id)
549
+ if status:
550
+ conditions.append("status = ?")
551
+ params.append(status)
552
+ if source:
553
+ conditions.append("source = ?")
554
+ params.append(source)
555
+
556
+ where_clause = " AND ".join(conditions) if conditions else "1=1"
557
+ params.append(limit)
558
+
559
+ # nosec B608: where_clause built from hardcoded condition strings, values parameterized
560
+ rows = self.db.fetchall(
561
+ f"""
562
+ SELECT * FROM sessions
563
+ WHERE {where_clause}
564
+ ORDER BY updated_at DESC
565
+ LIMIT ?
566
+ """, # nosec B608
567
+ tuple(params),
568
+ )
569
+ return [Session.from_row(row) for row in rows]
570
+
571
+ def count(
572
+ self,
573
+ project_id: str | None = None,
574
+ status: str | None = None,
575
+ source: str | None = None,
576
+ ) -> int:
577
+ """
578
+ Count sessions with optional filters.
579
+
580
+ Args:
581
+ project_id: Filter by project
582
+ status: Filter by status
583
+ source: Filter by CLI source
584
+
585
+ Returns:
586
+ Count of matching sessions
587
+ """
588
+ conditions = []
589
+ params: list[Any] = []
590
+
591
+ if project_id:
592
+ conditions.append("project_id = ?")
593
+ params.append(project_id)
594
+ if status:
595
+ conditions.append("status = ?")
596
+ params.append(status)
597
+ if source:
598
+ conditions.append("source = ?")
599
+ params.append(source)
600
+
601
+ where_clause = " AND ".join(conditions) if conditions else "1=1"
602
+
603
+ # nosec B608: where_clause built from hardcoded condition strings, values parameterized
604
+ result = self.db.fetchone(
605
+ f"SELECT COUNT(*) as count FROM sessions WHERE {where_clause}", # nosec B608
606
+ tuple(params),
607
+ )
608
+ return result["count"] if result else 0
609
+
610
+ def count_by_status(self) -> dict[str, int]:
611
+ """
612
+ Count sessions grouped by status.
613
+
614
+ Returns:
615
+ Dictionary mapping status to count
616
+ """
617
+ rows = self.db.fetchall("SELECT status, COUNT(*) as count FROM sessions GROUP BY status")
618
+ return {row["status"]: row["count"] for row in rows}
619
+
620
+ def delete(self, session_id: str) -> bool:
621
+ """Delete session by ID."""
622
+ cursor = self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
623
+ return bool(cursor.rowcount and cursor.rowcount > 0)
624
+
625
+ def expire_stale_sessions(self, timeout_hours: int = 24) -> int:
626
+ """
627
+ Mark sessions as expired if they've been inactive for too long.
628
+
629
+ Args:
630
+ timeout_hours: Hours of inactivity before expiring
631
+
632
+ Returns:
633
+ Number of sessions expired
634
+ """
635
+ now = datetime.now(UTC).isoformat()
636
+ cursor = self.db.execute(
637
+ """
638
+ UPDATE sessions
639
+ SET status = 'expired', updated_at = ?
640
+ WHERE status IN ('active', 'paused', 'handoff_ready')
641
+ AND datetime(updated_at) < datetime('now', 'utc', ? || ' hours')
642
+ """,
643
+ (now, f"-{timeout_hours}"),
644
+ )
645
+ count = cursor.rowcount or 0
646
+ if count > 0:
647
+ logger.info(f"Expired {count} stale sessions (>{timeout_hours}h inactive)")
648
+ return count
649
+
650
+ def pause_inactive_active_sessions(self, timeout_minutes: int = 30) -> int:
651
+ """
652
+ Mark active sessions as paused if they've been inactive for too long.
653
+
654
+ This catches orphaned sessions that never received an AFTER_AGENT hook
655
+ (e.g., Claude Code crashed mid-response).
656
+
657
+ Args:
658
+ timeout_minutes: Minutes of inactivity before pausing
659
+
660
+ Returns:
661
+ Number of sessions paused
662
+ """
663
+ now = datetime.now(UTC).isoformat()
664
+ cursor = self.db.execute(
665
+ """
666
+ UPDATE sessions
667
+ SET status = 'paused', updated_at = ?
668
+ WHERE status = 'active'
669
+ AND datetime(updated_at) < datetime('now', 'utc', ? || ' minutes')
670
+ """,
671
+ (now, f"-{timeout_minutes}"),
672
+ )
673
+ count = cursor.rowcount or 0
674
+ if count > 0:
675
+ logger.info(f"Paused {count} inactive active sessions (>{timeout_minutes}m)")
676
+ return count
677
+
678
+ def get_pending_transcript_sessions(self, limit: int = 10) -> builtins.list[Session]:
679
+ """
680
+ Get sessions that need transcript processing.
681
+
682
+ These are expired sessions with transcript_processed = FALSE.
683
+
684
+ Args:
685
+ limit: Maximum sessions to return
686
+
687
+ Returns:
688
+ List of sessions needing processing
689
+ """
690
+ rows = self.db.fetchall(
691
+ """
692
+ SELECT * FROM sessions
693
+ WHERE status = 'expired'
694
+ AND transcript_processed = FALSE
695
+ AND jsonl_path IS NOT NULL
696
+ ORDER BY updated_at ASC
697
+ LIMIT ?
698
+ """,
699
+ (limit,),
700
+ )
701
+ return [Session.from_row(row) for row in rows]
702
+
703
+ def mark_transcript_processed(self, session_id: str) -> Session | None:
704
+ """
705
+ Mark a session's transcript as fully processed.
706
+
707
+ Args:
708
+ session_id: Session ID
709
+
710
+ Returns:
711
+ Updated session or None if not found
712
+ """
713
+ now = datetime.now(UTC).isoformat()
714
+ self.db.execute(
715
+ "UPDATE sessions SET transcript_processed = TRUE, updated_at = ? WHERE id = ?",
716
+ (now, session_id),
717
+ )
718
+ return self.get(session_id)
719
+
720
+ def reset_transcript_processed(self, session_id: str) -> Session | None:
721
+ """
722
+ Reset transcript_processed flag when a session is resumed.
723
+
724
+ Args:
725
+ session_id: Session ID
726
+
727
+ Returns:
728
+ Updated session or None if not found
729
+ """
730
+ now = datetime.now(UTC).isoformat()
731
+ self.db.execute(
732
+ "UPDATE sessions SET transcript_processed = FALSE, updated_at = ? WHERE id = ?",
733
+ (now, session_id),
734
+ )
735
+ return self.get(session_id)
736
+
737
+ def update_usage(
738
+ self,
739
+ session_id: str,
740
+ input_tokens: int,
741
+ output_tokens: int,
742
+ cache_creation_tokens: int,
743
+ cache_read_tokens: int,
744
+ total_cost_usd: float,
745
+ ) -> bool:
746
+ """Update session usage statistics."""
747
+ query = """
748
+ UPDATE sessions
749
+ SET
750
+ usage_input_tokens = ?,
751
+ usage_output_tokens = ?,
752
+ usage_cache_creation_tokens = ?,
753
+ usage_cache_read_tokens = ?,
754
+ usage_total_cost_usd = ?,
755
+ updated_at = datetime('now')
756
+ WHERE id = ?
757
+ """
758
+ try:
759
+ with self.db.transaction():
760
+ cursor = self.db.execute(
761
+ query,
762
+ (
763
+ input_tokens,
764
+ output_tokens,
765
+ cache_creation_tokens,
766
+ cache_read_tokens,
767
+ total_cost_usd,
768
+ session_id,
769
+ ),
770
+ )
771
+ return cursor.rowcount > 0
772
+ except Exception as e:
773
+ logger.error(f"Failed to update session usage {session_id}: {e}")
774
+ return False
775
+
776
+ def update_terminal_pickup_metadata(
777
+ self,
778
+ session_id: str,
779
+ workflow_name: str | None = None,
780
+ agent_run_id: str | None = None,
781
+ context_injected: bool | None = None,
782
+ original_prompt: str | None = None,
783
+ ) -> Session | None:
784
+ """
785
+ Update terminal pickup metadata for a session.
786
+
787
+ These fields are used when a terminal-mode agent picks up its
788
+ prepared state via hooks on session start.
789
+
790
+ Args:
791
+ session_id: Session ID to update.
792
+ workflow_name: Workflow to activate on terminal pickup.
793
+ agent_run_id: Link back to the agent run record.
794
+ context_injected: Whether context was injected into prompt.
795
+ original_prompt: Original prompt for the agent.
796
+
797
+ Returns:
798
+ Updated session or None if not found.
799
+ """
800
+ values: dict[str, Any] = {}
801
+
802
+ if workflow_name is not None:
803
+ values["workflow_name"] = workflow_name
804
+ if agent_run_id is not None:
805
+ values["agent_run_id"] = agent_run_id
806
+ if context_injected is not None:
807
+ values["context_injected"] = 1 if context_injected else 0
808
+ if original_prompt is not None:
809
+ values["original_prompt"] = original_prompt
810
+
811
+ if not values:
812
+ return self.get(session_id)
813
+
814
+ values["updated_at"] = datetime.now(UTC).isoformat()
815
+
816
+ self.db.safe_update("sessions", values, "id = ?", (session_id,))
817
+ return self.get(session_id)