agentpool-cli 0.1.0__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 (60) hide show
  1. agentpool/__init__.py +3 -0
  2. agentpool/agent_io.py +134 -0
  3. agentpool/artifacts.py +151 -0
  4. agentpool/cli.py +1199 -0
  5. agentpool/config.py +373 -0
  6. agentpool/docs/agentpool-skill.md +85 -0
  7. agentpool/docs/onboarding.md +169 -0
  8. agentpool/event_detection.py +150 -0
  9. agentpool/fixtures/__init__.py +1 -0
  10. agentpool/fixtures/fake_agents/__init__.py +1 -0
  11. agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
  12. agentpool/fixtures/fake_agents/fake_common.py +44 -0
  13. agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
  14. agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
  15. agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
  16. agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
  17. agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
  18. agentpool/git_worktree.py +144 -0
  19. agentpool/mcp/__init__.py +1 -0
  20. agentpool/mcp/resources.py +64 -0
  21. agentpool/mcp/tools.py +259 -0
  22. agentpool/mcp_server.py +487 -0
  23. agentpool/models.py +310 -0
  24. agentpool/onboarding.py +1279 -0
  25. agentpool/policy.py +63 -0
  26. agentpool/provider_model_catalog.json +997 -0
  27. agentpool/providers/__init__.py +3 -0
  28. agentpool/providers/base.py +411 -0
  29. agentpool/providers/registry.py +139 -0
  30. agentpool/redaction.py +30 -0
  31. agentpool/runtimes/__init__.py +3 -0
  32. agentpool/runtimes/base.py +36 -0
  33. agentpool/runtimes/tmux.py +133 -0
  34. agentpool/session_manager.py +1061 -0
  35. agentpool/stats/__init__.py +6 -0
  36. agentpool/stats/card.py +74 -0
  37. agentpool/stats/compute.py +496 -0
  38. agentpool/stats/queries.py +138 -0
  39. agentpool/stats/render.py +103 -0
  40. agentpool/stats/window.py +85 -0
  41. agentpool/store.py +478 -0
  42. agentpool/usage/__init__.py +1 -0
  43. agentpool/usage/_common.py +223 -0
  44. agentpool/usage/ccusage.py +130 -0
  45. agentpool/usage/claude.py +23 -0
  46. agentpool/usage/codex.py +210 -0
  47. agentpool/usage/codexbar.py +186 -0
  48. agentpool/usage/combine.py +71 -0
  49. agentpool/usage/copilot.py +146 -0
  50. agentpool/usage/devin.py +265 -0
  51. agentpool/usage/parsers.py +41 -0
  52. agentpool/usage/probes.py +52 -0
  53. agentpool/usage/provider_parsers.py +276 -0
  54. agentpool/usage/summary.py +166 -0
  55. agentpool/utils.py +59 -0
  56. agentpool_cli-0.1.0.dist-info/METADATA +292 -0
  57. agentpool_cli-0.1.0.dist-info/RECORD +60 -0
  58. agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
  59. agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  60. agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1061 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import shutil
5
+ import uuid
6
+ from collections import deque
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from agentpool.artifacts import (
12
+ append_transcript,
13
+ artifact_manifest,
14
+ collect_artifacts,
15
+ create_artifact_dir,
16
+ initialize_artifacts,
17
+ )
18
+ from agentpool.config import AgentPoolConfig, load_config
19
+ from agentpool.config import default_provider_config
20
+ from agentpool.event_detection import detect_event, screen_hash, trim_excerpt
21
+ from agentpool.git_worktree import cleanup_worktree, create_worktree, delete_agentpool_branch, list_agentpool_worktrees
22
+ from agentpool.models import (
23
+ AgentSession,
24
+ ArtifactRecord,
25
+ FileLease,
26
+ ObserveEvent,
27
+ ObserveWorkerResponse,
28
+ RuntimeKind,
29
+ SessionState,
30
+ SpawnWorkerRequest,
31
+ ToolError,
32
+ )
33
+ from agentpool.policy import active_state, enforce_raw_keys_policy, enforce_spawn_policy
34
+ from agentpool.providers.registry import ProviderRegistry, build_registry
35
+ from agentpool.redaction import redact_text
36
+ from agentpool.runtimes.tmux import TmuxRuntime
37
+ from agentpool.store import Store
38
+ from agentpool.usage.summary import build_usage_summary
39
+ from agentpool.utils import append_jsonl, new_session_id, utc_now_iso, write_json
40
+
41
+ DEFAULT_SESSION_LIMIT = 50
42
+ MAX_SESSION_LIMIT = 500
43
+
44
+
45
+ class SessionManager:
46
+ def __init__(
47
+ self,
48
+ config: AgentPoolConfig | None = None,
49
+ store: Store | None = None,
50
+ registry: ProviderRegistry | None = None,
51
+ runtime: TmuxRuntime | None = None,
52
+ coordinator_id: str | None = None,
53
+ scope_sessions_by_coordinator: bool = False,
54
+ ):
55
+ self.config = config or load_config()
56
+ if not self.config.providers:
57
+ self.config.providers = default_provider_config()
58
+ self.store = store or Store(self.config.storage.db)
59
+ self.registry = registry or build_registry(self.config)
60
+ self.runtime = runtime or TmuxRuntime()
61
+ self.coordinator_id = coordinator_id or f"coord_{uuid.uuid4().hex[:12]}"
62
+ self.scope_sessions_by_coordinator = scope_sessions_by_coordinator
63
+
64
+ def inventory(self, include_usage: bool = True) -> dict[str, Any]:
65
+ self.reconcile_sessions()
66
+ return {
67
+ "providers": [p.model_dump(mode="json") for p in self.registry.descriptors(include_usage)],
68
+ "policy": self.config.policy.model_dump(mode="json"),
69
+ "checked_at": utc_now_iso(),
70
+ }
71
+
72
+ def usage_snapshot(self, provider_id: str | None = None, backend: str = "combined") -> dict[str, Any]:
73
+ self.reconcile_sessions()
74
+ snapshots = self.registry.usage(provider_id, backend=backend)
75
+ for snapshot in snapshots:
76
+ self.store.save_usage_snapshot(snapshot)
77
+ return {
78
+ "snapshots": [snapshot.model_dump(mode="json") for snapshot in snapshots],
79
+ "source": "live_probe",
80
+ "backend": backend,
81
+ }
82
+
83
+ def cached_usage_snapshot(self, provider_id: str | None = None) -> dict[str, Any]:
84
+ self.reconcile_sessions()
85
+ snapshots = self._configured_usage_snapshots(self.store.latest_usage_snapshots(provider_id), provider_id)
86
+ return {
87
+ "snapshots": [snapshot.model_dump(mode="json") for snapshot in snapshots],
88
+ "source": "sqlite_cache",
89
+ }
90
+
91
+ def usage_summary(self, provider_id: str | None = None, refresh: bool = False, backend: str = "combined") -> dict[str, Any]:
92
+ self.reconcile_sessions()
93
+ descriptors = self.registry.descriptors(include_usage=False)
94
+ if refresh:
95
+ snapshots = self.registry.usage(provider_id, backend=backend)
96
+ for snapshot in snapshots:
97
+ self.store.save_usage_snapshot(snapshot)
98
+ source = "live_probe"
99
+ else:
100
+ snapshots = self._configured_usage_snapshots(self.store.latest_usage_snapshots(provider_id), provider_id)
101
+ source = "sqlite_cache"
102
+ return {
103
+ **build_usage_summary(
104
+ snapshots,
105
+ min_remaining_percent=self.config.policy.min_remaining_percent,
106
+ stale_after_seconds=self.config.policy.usage_stale_after_seconds,
107
+ provider_descriptors=descriptors,
108
+ ),
109
+ "source": source,
110
+ "backend": backend if refresh else "cache",
111
+ }
112
+
113
+ def provider_models(self, provider_id: str | None = None) -> dict[str, Any]:
114
+ rows = []
115
+ for descriptor in self.inventory(include_usage=False)["providers"]:
116
+ if provider_id and descriptor["id"] != provider_id:
117
+ continue
118
+ metadata = descriptor.get("metadata") or {}
119
+ rows.append(
120
+ {
121
+ "provider_id": descriptor["id"],
122
+ "installed": descriptor["installed"],
123
+ "default_model": metadata.get("default_model"),
124
+ "smoke_model": metadata.get("smoke_model"),
125
+ "model_arg": metadata.get("model_arg"),
126
+ "model_selection": metadata.get("model_selection", "model_arg"),
127
+ "default_initial_prompt_mode": metadata.get("default_initial_prompt_mode", "send_after_launch"),
128
+ "reasoning_effort_config_key": metadata.get("reasoning_effort_config_key"),
129
+ "service_tier_config_key": metadata.get("service_tier_config_key"),
130
+ "submit_keys": metadata.get("submit_keys", []),
131
+ "catalog_completeness": metadata.get("catalog_completeness"),
132
+ "quirks": metadata.get("quirks", []),
133
+ "models": descriptor.get("models", []),
134
+ }
135
+ )
136
+ if provider_id and not rows:
137
+ raise ToolError(
138
+ "PROVIDER_NOT_FOUND",
139
+ f"Provider {provider_id} is not configured.",
140
+ {"provider_id": provider_id},
141
+ )
142
+ return {"providers": rows}
143
+
144
+ def filter_candidates(
145
+ self,
146
+ required_capabilities: list[str] | None = None,
147
+ avoid_statuses: list[str] | None = None,
148
+ allowed_providers: list[str] | None = None,
149
+ include_usage_unknown: bool = True,
150
+ ) -> dict[str, Any]:
151
+ required = set(required_capabilities or [])
152
+ avoid = set(avoid_statuses or [])
153
+ allowed = set(allowed_providers or [])
154
+ candidates: list[dict[str, Any]] = []
155
+ excluded: list[dict[str, Any]] = []
156
+ for descriptor in self.registry.descriptors(include_usage=True):
157
+ reasons: list[str] = []
158
+ blocked: list[str] = []
159
+ if allowed and descriptor.id not in allowed:
160
+ blocked.append("not in allowed_providers filter")
161
+ if not descriptor.installed:
162
+ blocked.append("not installed")
163
+ capabilities = {str(cap.value if hasattr(cap, "value") else cap) for cap in descriptor.capabilities}
164
+ missing = sorted(required - capabilities)
165
+ if missing:
166
+ blocked.append(f"missing capabilities: {', '.join(missing)}")
167
+ status = descriptor.usage.status if descriptor.usage else "unknown"
168
+ status_value = status.value if hasattr(status, "value") else str(status)
169
+ if status_value in avoid:
170
+ blocked.append(f"usage status avoided: {status_value}")
171
+ if status_value == "unknown" and not include_usage_unknown:
172
+ blocked.append("usage unknown")
173
+ if descriptor.id in self.config.policy.denied_providers:
174
+ blocked.append("policy: provider denied")
175
+ if blocked:
176
+ excluded.append({"provider_id": descriptor.id, "excluded_reasons": blocked})
177
+ else:
178
+ reasons.extend(["installed", "supports tmux"])
179
+ reasons.append("usage available or unknown" if include_usage_unknown else f"usage {status_value}")
180
+ candidates.append({"provider_id": descriptor.id, "included_reasons": reasons})
181
+ return {"candidates": candidates, "excluded": excluded}
182
+
183
+ def spawn_worker(self, request: SpawnWorkerRequest) -> dict[str, Any]:
184
+ self.reconcile_sessions()
185
+ enforce_spawn_policy(self.config, request.provider_id, request.role, request.isolation)
186
+ if request.runtime != RuntimeKind.TMUX:
187
+ raise ToolError("POLICY_BLOCKED", "Only tmux runtime is supported in v0.1.", {"runtime": request.runtime})
188
+ active_sessions = [
189
+ session
190
+ for session in self.store.list_sessions()
191
+ if active_state(session.state) and self._session_in_scope(session)
192
+ ]
193
+ if len(active_sessions) >= self.config.policy.max_parallel_sessions:
194
+ raise ToolError(
195
+ "POLICY_BLOCKED",
196
+ "Max parallel sessions reached.",
197
+ {
198
+ "max_parallel_sessions": self.config.policy.max_parallel_sessions,
199
+ "active_count": len(active_sessions),
200
+ "active_sessions": [_session_summary(session) for session in active_sessions],
201
+ },
202
+ )
203
+ self._enforce_cached_usage_policy(request.provider_id)
204
+ adapter = self.registry.get(request.provider_id)
205
+ descriptor = adapter.detect()
206
+ if not descriptor.installed:
207
+ raise ToolError(
208
+ "PROVIDER_NOT_INSTALLED",
209
+ f"Provider {request.provider_id} is not installed.",
210
+ {"provider_id": request.provider_id, "warnings": descriptor.warnings},
211
+ )
212
+ model_was_explicit = request.model is not None
213
+ default_model = _default_model(adapter.config.metadata)
214
+ if not request.model and default_model:
215
+ request = request.model_copy(update={"model": default_model})
216
+ if model_was_explicit and not request.reasoning_effort:
217
+ default_reasoning = _default_reasoning_effort(adapter.config.metadata, adapter.config.models, request.model)
218
+ if default_reasoning:
219
+ request = request.model_copy(update={"reasoning_effort": default_reasoning})
220
+ effective_prompt_mode = _effective_initial_prompt_mode(request.initial_prompt_mode, adapter.config.metadata)
221
+ if effective_prompt_mode != request.initial_prompt_mode:
222
+ request = request.model_copy(update={"initial_prompt_mode": effective_prompt_mode})
223
+ if request.max_runtime_seconds is not None and request.max_runtime_seconds <= 0:
224
+ raise ToolError(
225
+ "INVALID_LIMIT",
226
+ "max_runtime_seconds must be greater than zero when provided.",
227
+ {"max_runtime_seconds": request.max_runtime_seconds},
228
+ )
229
+ if request.max_turns is not None and request.max_turns <= 0:
230
+ raise ToolError(
231
+ "INVALID_LIMIT",
232
+ "max_turns must be greater than zero when provided.",
233
+ {"max_turns": request.max_turns},
234
+ )
235
+ repo_path = Path(request.repo_path).expanduser().resolve()
236
+ session_id = new_session_id()
237
+ workdir = repo_path
238
+ worktree_path = None
239
+ artifact_dir = None
240
+ try:
241
+ if request.isolation == "worktree":
242
+ workdir = create_worktree(repo_path, request.provider_id, session_id)
243
+ worktree_path = str(workdir)
244
+ artifact_dir = create_artifact_dir(self.config.storage.artifacts, repo_path, session_id)
245
+ except Exception:
246
+ if worktree_path:
247
+ self._rollback_spawn_files(repo_path, worktree_path, request.provider_id, session_id, artifact_dir)
248
+ raise
249
+ tmux_name = _tmux_name(self.config.runtime.tmux.session_prefix, request.provider_id, session_id)
250
+ now = datetime.now(timezone.utc)
251
+ metadata = dict(request.metadata)
252
+ metadata["coordinator_id"] = self.coordinator_id
253
+ metadata["initial_prompt_mode"] = request.initial_prompt_mode
254
+ if request.reasoning_effort:
255
+ metadata["reasoning_effort"] = request.reasoning_effort
256
+ if request.service_tier:
257
+ metadata["service_tier"] = request.service_tier
258
+ if request.max_runtime_seconds:
259
+ metadata["deadline_at"] = datetime.fromtimestamp(
260
+ time.time() + request.max_runtime_seconds,
261
+ tz=timezone.utc,
262
+ ).isoformat()
263
+ if request.max_turns:
264
+ metadata["max_turns"] = request.max_turns
265
+ metadata["turns_sent"] = 0
266
+ session = AgentSession(
267
+ id=session_id,
268
+ provider_id=request.provider_id,
269
+ model=request.model,
270
+ harness=adapter.harness,
271
+ account=request.account,
272
+ role=request.role,
273
+ task=request.task,
274
+ repo_path=str(repo_path),
275
+ worktree_path=worktree_path,
276
+ runtime=RuntimeKind.TMUX,
277
+ state=SessionState.STARTING,
278
+ created_at=now,
279
+ updated_at=now,
280
+ artifact_dir=str(artifact_dir),
281
+ transcript_path=str(artifact_dir / "transcript.txt"),
282
+ events_path=str(artifact_dir / "events.jsonl"),
283
+ metadata=metadata,
284
+ )
285
+ self.store.save_session(session)
286
+ try:
287
+ prompt = adapter.build_initial_prompt(request, session_id, workdir)
288
+ initialize_artifacts(session, prompt)
289
+ command = adapter.build_launch_command(request, workdir)
290
+ if request.initial_prompt_mode == "arg":
291
+ command = [*command, prompt]
292
+ ref = self.runtime.spawn(command, workdir, {}, tmux_name)
293
+ except Exception:
294
+ self.store.update_session_state(session_id, SessionState.FAILED, ended_at=utc_now_iso())
295
+ self._rollback_spawn_files(repo_path, worktree_path, request.provider_id, session_id, artifact_dir)
296
+ raise
297
+ try:
298
+ session.tmux = ref
299
+ session.state = SessionState.RUNNING if request.initial_prompt_mode == "arg" else SessionState.READY
300
+ session.updated_at = datetime.now(timezone.utc)
301
+ self.store.save_session(session)
302
+ except Exception:
303
+ if self.runtime.exists(ref):
304
+ self.runtime.terminate(ref)
305
+ self.store.update_session_state(session_id, SessionState.FAILED, ended_at=utc_now_iso())
306
+ self._rollback_spawn_files(repo_path, worktree_path, request.provider_id, session_id, artifact_dir)
307
+ raise
308
+ event_command = list(command)
309
+ if request.initial_prompt_mode == "arg" and event_command:
310
+ event_command[-1] = "<agentpool-initial-prompt>"
311
+ self._event(
312
+ session,
313
+ "spawn",
314
+ state=session.state.value,
315
+ metadata={"command": event_command, "initial_prompt_mode": request.initial_prompt_mode},
316
+ )
317
+ if request.initial_prompt_mode == "send_after_launch":
318
+ time.sleep(0.3)
319
+ self.runtime.send_message(ref, prompt, submit=True)
320
+ session.state = SessionState.RUNNING
321
+ session.updated_at = datetime.now(timezone.utc)
322
+ session.metadata["initial_prompt_sent"] = True
323
+ self.store.save_session(session)
324
+ self._event(session, "send_initial_prompt", state=session.state.value)
325
+ return {
326
+ "session": session.model_dump(mode="json"),
327
+ "attach_command": self.runtime.attach_command(ref),
328
+ "live_control": {
329
+ "can_capture_screen": True,
330
+ "can_send_message": True,
331
+ "can_send_keys": self.config.policy.allow_raw_keys,
332
+ "can_interrupt": True,
333
+ "can_attach": True,
334
+ "initial_prompt_mode": request.initial_prompt_mode,
335
+ },
336
+ }
337
+
338
+ def observe_worker(
339
+ self,
340
+ session_id: str,
341
+ wait_for: list[str] | None = None,
342
+ timeout_seconds: int = 0,
343
+ include_screen: bool = True,
344
+ include_recent_log: bool = True,
345
+ max_lines: int | None = None,
346
+ ) -> ObserveWorkerResponse:
347
+ session = self._require_session(session_id)
348
+ timed_out = self._enforce_deadline(session)
349
+ if timed_out:
350
+ return timed_out
351
+ if not session.tmux:
352
+ raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
353
+ deadline = time.monotonic() + max(0, timeout_seconds)
354
+ wanted = set(wait_for or [])
355
+ previous_hash = session.metadata.get("last_screen_hash")
356
+ while True:
357
+ timed_out = self._enforce_deadline(session)
358
+ if timed_out:
359
+ return timed_out
360
+ screen = self.runtime.capture(session.tmux, max_lines or self.config.runtime.tmux.capture_lines)
361
+ clean = redact_text(screen)
362
+ detection = detect_event(clean, previous_hash)
363
+ current_hash = screen_hash(clean)
364
+ readiness = _classify_readiness(session, detection, previous_hash, current_hash, clean)
365
+ observe_metadata = {
366
+ "screen_hash": current_hash,
367
+ "readiness": readiness,
368
+ "unchanged_screen": bool(previous_hash and previous_hash == current_hash),
369
+ "startup_warnings": _startup_warning_summary(clean),
370
+ }
371
+ session.state = detection.state
372
+ session.updated_at = datetime.now(timezone.utc)
373
+ session.metadata["last_screen_hash"] = current_hash
374
+ self.store.save_session(session)
375
+ append_transcript(session, clean)
376
+ (Path(session.artifact_dir) / "latest_screen.txt").write_text(clean, encoding="utf-8")
377
+ self._event(
378
+ session,
379
+ f"observe:{detection.event.value}",
380
+ state=session.state.value,
381
+ screen_hash=current_hash,
382
+ excerpt=trim_excerpt(clean, 1000),
383
+ metadata={"readiness": readiness},
384
+ )
385
+ event_value = detection.event.value
386
+ if not wanted or event_value in wanted or _alias_event(event_value) in wanted or timeout_seconds <= 0:
387
+ return ObserveWorkerResponse(
388
+ session_id=session_id,
389
+ state=session.state,
390
+ event=detection.event,
391
+ screen_excerpt=trim_excerpt(clean) if include_screen else None,
392
+ recent_log=trim_excerpt(clean, 2000) if include_recent_log else None,
393
+ parsed_question=detection.parsed_question,
394
+ confidence=detection.confidence,
395
+ metadata=observe_metadata,
396
+ )
397
+ if time.monotonic() >= deadline:
398
+ return ObserveWorkerResponse(
399
+ session_id=session_id,
400
+ state=session.state,
401
+ event=ObserveEvent.TIMEOUT,
402
+ screen_excerpt=trim_excerpt(clean) if include_screen else None,
403
+ recent_log=trim_excerpt(clean, 2000) if include_recent_log else None,
404
+ confidence=detection.confidence,
405
+ metadata={**observe_metadata, "readiness": "timeout"},
406
+ )
407
+ previous_hash = current_hash
408
+ time.sleep(0.5)
409
+
410
+ def send_worker_message(self, session_id: str, message: str, submit: bool = True) -> dict[str, Any]:
411
+ session = self._require_session(session_id)
412
+ self._enforce_deadline_or_raise(session)
413
+ self._enforce_turn_limit(session)
414
+ if not session.tmux:
415
+ raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
416
+ submit_keys = None
417
+ sent_empty_submit = False
418
+ if submit and message == "":
419
+ submit_keys = ["Enter"]
420
+ self.runtime.send_keys(session.tmux, submit_keys)
421
+ sent_empty_submit = True
422
+ elif submit and not _looks_like_menu_choice(message):
423
+ submit_keys = self.registry.get(session.provider_id).submit_keys()
424
+ if submit_keys and not sent_empty_submit:
425
+ self.runtime.send_message(session.tmux, message, submit=False)
426
+ time.sleep(0.1)
427
+ self.runtime.send_keys(session.tmux, submit_keys)
428
+ elif not sent_empty_submit and (message != "" or not submit):
429
+ self.runtime.send_message(session.tmux, message, submit=submit)
430
+ event_id = self._event(
431
+ session,
432
+ "send_message",
433
+ state=session.state.value,
434
+ metadata={"submit": submit, "submit_keys": submit_keys},
435
+ )
436
+ session.metadata["turns_sent"] = int(session.metadata.get("turns_sent") or 0) + 1
437
+ session.updated_at = datetime.now(timezone.utc)
438
+ self.store.save_session(session)
439
+ append_transcript(session, f"\n[agentpool sent]\n{redact_text(message)}\n")
440
+ return {"ok": True, "session_id": session_id, "event_id": event_id}
441
+
442
+ def send_worker_keys(self, session_id: str, keys: list[str]) -> dict[str, Any]:
443
+ enforce_raw_keys_policy(self.config, keys)
444
+ session = self._require_session(session_id)
445
+ self._enforce_deadline_or_raise(session)
446
+ if not session.tmux:
447
+ raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
448
+ self.runtime.send_keys(session.tmux, keys)
449
+ self._event(session, "send_keys", state=session.state.value, metadata={"keys": keys})
450
+ return {"ok": True, "session_id": session_id}
451
+
452
+ def interrupt_worker(self, session_id: str) -> dict[str, Any]:
453
+ session = self._require_session(session_id)
454
+ if not session.tmux:
455
+ raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
456
+ self.runtime.interrupt(session.tmux)
457
+ self._event(session, "interrupt", state=session.state.value)
458
+ return {"ok": True, "state": session.state.value if hasattr(session.state, "value") else session.state}
459
+
460
+ def attach_info(self, session_id: str) -> dict[str, Any]:
461
+ session = self._require_session(session_id)
462
+ if not session.tmux:
463
+ raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
464
+ return {
465
+ "session_id": session_id,
466
+ "attach_command": self.runtime.attach_command(session.tmux),
467
+ "tmux_session": session.tmux.session_name,
468
+ "pane_target": session.tmux.target,
469
+ }
470
+
471
+ def collect_worker_artifacts(
472
+ self,
473
+ session_id: str,
474
+ include_diff: bool = True,
475
+ include_transcript: bool = True,
476
+ mark_completed: bool = False,
477
+ ) -> dict[str, Any]:
478
+ session = self._require_session(session_id)
479
+ screen = ""
480
+ if session.tmux and self.runtime.exists(session.tmux):
481
+ try:
482
+ screen = self.runtime.capture(session.tmux, self.config.runtime.tmux.capture_lines)
483
+ except ToolError as exc:
484
+ if exc.error.code != "TMUX_SESSION_NOT_FOUND":
485
+ raise
486
+ latest_screen = Path(session.artifact_dir) / "latest_screen.txt"
487
+ screen = latest_screen.read_text(encoding="utf-8") if latest_screen.exists() else ""
488
+ screen = redact_text(screen)
489
+ result = collect_artifacts(session, screen, include_diff=include_diff)
490
+ for artifact in result["artifacts"]:
491
+ self.store.save_artifact(session_id, artifact=ArtifactRecord.model_validate(artifact))
492
+ if mark_completed and session.state not in {SessionState.CANCELLED, SessionState.FAILED}:
493
+ session.state = SessionState.COMPLETED
494
+ self.store.update_session_state(session_id, SessionState.COMPLETED, ended_at=utc_now_iso())
495
+ result["state"] = SessionState.COMPLETED.value
496
+ self._event(session, "collect", state=result["state"], metadata={"include_diff": include_diff})
497
+ if not include_transcript:
498
+ result["artifacts"] = [a for a in result["artifacts"] if a["kind"] != "transcript"]
499
+ return result
500
+
501
+ def artifact_manifest(self, session_id: str) -> dict[str, Any]:
502
+ return artifact_manifest(self._require_session(session_id))
503
+
504
+ def read_transcript(
505
+ self,
506
+ session_id: str,
507
+ offset: int = 0,
508
+ limit: int = 4000,
509
+ tail_lines: int | None = None,
510
+ ) -> dict[str, Any]:
511
+ session = self._require_session(session_id)
512
+ if offset < 0:
513
+ raise ToolError("INVALID_TRANSCRIPT_RANGE", "offset must be zero or greater.", {"offset": offset})
514
+ if limit <= 0 or limit > 200_000:
515
+ raise ToolError(
516
+ "INVALID_TRANSCRIPT_RANGE",
517
+ "limit must be between 1 and 200000 bytes.",
518
+ {"limit": limit},
519
+ )
520
+ if tail_lines is not None and (tail_lines <= 0 or tail_lines > 10_000):
521
+ raise ToolError(
522
+ "INVALID_TRANSCRIPT_RANGE",
523
+ "tail_lines must be between 1 and 10000.",
524
+ {"tail_lines": tail_lines},
525
+ )
526
+ if tail_lines is not None and offset:
527
+ raise ToolError(
528
+ "INVALID_TRANSCRIPT_RANGE",
529
+ "Use either offset pagination or tail_lines, not both.",
530
+ {"offset": offset, "tail_lines": tail_lines},
531
+ )
532
+ path = Path(session.transcript_path)
533
+ if not path.exists():
534
+ return _transcript_payload(session, path, "", offset=0, next_offset=0, size_bytes=0, mode="missing")
535
+ size_bytes = path.stat().st_size
536
+ if tail_lines is not None:
537
+ lines = deque(maxlen=tail_lines)
538
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
539
+ lines.extend(fh)
540
+ text = "".join(lines)
541
+ return _transcript_payload(
542
+ session,
543
+ path,
544
+ text,
545
+ offset=None,
546
+ next_offset=None,
547
+ size_bytes=size_bytes,
548
+ mode="tail",
549
+ tail_lines=tail_lines,
550
+ has_more=len(lines) == tail_lines and bool(size_bytes),
551
+ )
552
+ start = min(offset, size_bytes)
553
+ with path.open("rb") as fh:
554
+ fh.seek(start)
555
+ raw = fh.read(limit)
556
+ next_offset = start + len(raw)
557
+ return _transcript_payload(
558
+ session,
559
+ path,
560
+ raw.decode("utf-8", errors="replace"),
561
+ offset=start,
562
+ next_offset=next_offset,
563
+ size_bytes=size_bytes,
564
+ mode="page",
565
+ limit=limit,
566
+ has_more=next_offset < size_bytes,
567
+ )
568
+
569
+ def acquire_file_lease(
570
+ self,
571
+ session_id: str,
572
+ file_path: str,
573
+ mode: str = "write",
574
+ ttl_seconds: int | None = None,
575
+ metadata: dict[str, Any] | None = None,
576
+ ) -> dict[str, Any]:
577
+ session = self._require_session(session_id)
578
+ if mode not in {"read", "write"}:
579
+ raise ToolError("INVALID_LEASE_MODE", "Lease mode must be read or write.", {"mode": mode})
580
+ expires_at = None
581
+ if ttl_seconds:
582
+ expires_at = datetime.fromtimestamp(time.time() + ttl_seconds, tz=timezone.utc).isoformat()
583
+ normalized = _normalize_file_path(session, file_path)
584
+ lease = self.store.acquire_file_lease(
585
+ session_id=session_id,
586
+ repo_path=session.repo_path,
587
+ file_path=normalized,
588
+ mode=mode,
589
+ expires_at=expires_at,
590
+ metadata=metadata,
591
+ )
592
+ if lease.session_id != session_id:
593
+ raise ToolError(
594
+ "LEASE_CONFLICT",
595
+ f"File is already leased by session {lease.session_id}.",
596
+ {"file_path": normalized, "lease": lease.model_dump(mode="json")},
597
+ )
598
+ self._event(session, "lease_acquire", state=session.state.value, metadata={"file_path": normalized, "mode": mode})
599
+ return {"ok": True, "lease": lease.model_dump(mode="json")}
600
+
601
+ def list_file_leases(
602
+ self,
603
+ session_id: str | None = None,
604
+ repo_path: str | None = None,
605
+ active_only: bool = True,
606
+ ) -> dict[str, Any]:
607
+ normalized_repo = str(Path(repo_path).expanduser().resolve()) if repo_path else None
608
+ leases = self.store.list_file_leases(session_id=session_id, repo_path=normalized_repo, active_only=active_only)
609
+ return {"leases": [lease.model_dump(mode="json") for lease in leases]}
610
+
611
+ def release_file_lease(
612
+ self,
613
+ lease_id: int | None = None,
614
+ session_id: str | None = None,
615
+ file_path: str | None = None,
616
+ ) -> dict[str, Any]:
617
+ normalized = file_path
618
+ if session_id and file_path:
619
+ normalized = _normalize_file_path(self._require_session(session_id), file_path)
620
+ released = self.store.release_file_lease(lease_id=lease_id, session_id=session_id, file_path=normalized)
621
+ if session_id:
622
+ session = self._require_session(session_id)
623
+ self._event(session, "lease_release", state=session.state.value, metadata={"lease_id": lease_id, "file_path": normalized})
624
+ return {"ok": True, "released": released}
625
+
626
+ def list_worktrees(self, repo_path: str) -> dict[str, Any]:
627
+ repo = Path(repo_path).expanduser().resolve()
628
+ active_worktrees = {
629
+ str(session.worktree_path)
630
+ for session in self.store.list_sessions()
631
+ if session.worktree_path and active_state(session.state)
632
+ }
633
+ worktrees = []
634
+ for entry in list_agentpool_worktrees(repo):
635
+ path = str(entry.get("path") or "")
636
+ worktrees.append({**entry, "active": path in active_worktrees})
637
+ return {"repo_path": str(repo), "worktrees": worktrees}
638
+
639
+ def cleanup_worktree(self, session_id: str, force: bool = False) -> dict[str, Any]:
640
+ session = self._require_session(session_id)
641
+ if not session.worktree_path:
642
+ return {"removed": False, "reason": "session has no worktree", "session_id": session_id}
643
+ if active_state(session.state) and not force:
644
+ raise ToolError(
645
+ "WORKTREE_ACTIVE",
646
+ "Refusing to remove an active session worktree; terminate first or pass force.",
647
+ {"session_id": session_id, "state": session.state.value if hasattr(session.state, "value") else session.state},
648
+ )
649
+ result = cleanup_worktree(Path(session.repo_path), Path(session.worktree_path), force=force)
650
+ self._event(session, "worktree_cleanup", state=session.state.value, metadata={"force": force, **result})
651
+ return {"session_id": session_id, **result}
652
+
653
+ def list_sessions(
654
+ self,
655
+ states: list[str] | str | None = None,
656
+ provider_id: str | None = None,
657
+ include_all: bool = False,
658
+ limit: int | None = DEFAULT_SESSION_LIMIT,
659
+ offset: int = 0,
660
+ ) -> dict[str, Any]:
661
+ self.reconcile_sessions()
662
+ limit = _normalize_session_limit(limit)
663
+ if offset < 0:
664
+ raise ToolError("INVALID_SESSION_PAGE", "offset must be zero or greater.", {"offset": offset})
665
+ normalized_states = _normalize_state_filter(states)
666
+ if self.scope_sessions_by_coordinator and not include_all:
667
+ all_sessions = [
668
+ session
669
+ for session in self.store.list_sessions(normalized_states, provider_id)
670
+ if self._session_in_scope(session)
671
+ ]
672
+ total = len(all_sessions)
673
+ sessions = all_sessions[offset:] if limit is None else all_sessions[offset : offset + limit]
674
+ else:
675
+ total = self.store.count_sessions(normalized_states, provider_id)
676
+ sessions = self.store.list_sessions(normalized_states, provider_id, limit=limit, offset=offset)
677
+ next_offset = offset + len(sessions)
678
+ has_more = next_offset < total
679
+ return {
680
+ "sessions": [session.model_dump(mode="json") for session in sessions],
681
+ "pagination": {
682
+ "limit": limit,
683
+ "offset": offset,
684
+ "count": len(sessions),
685
+ "total": total,
686
+ "has_more": has_more,
687
+ "next_offset": next_offset if has_more else None,
688
+ },
689
+ "scope": {
690
+ "coordinator_id": self.coordinator_id,
691
+ "current_coordinator_only": self.scope_sessions_by_coordinator and not include_all,
692
+ },
693
+ }
694
+
695
+ def get_session(self, session_id: str) -> dict[str, Any]:
696
+ return {"session": self._require_session(session_id).model_dump(mode="json")}
697
+
698
+ def terminate_worker(self, session_id: str, reason: str | None = None) -> dict[str, Any]:
699
+ session = self._require_session(session_id)
700
+ if session.tmux and self.runtime.exists(session.tmux):
701
+ self.runtime.terminate(session.tmux)
702
+ state = session.state if isinstance(session.state, SessionState) else SessionState(session.state)
703
+ final_state = state if not active_state(state) else SessionState.CANCELLED
704
+ self.store.update_session_state(session_id, final_state, ended_at=utc_now_iso())
705
+ self._event(session, "terminate", state=final_state.value, metadata={"reason": reason})
706
+ return {"ok": True, "state": final_state.value}
707
+
708
+ def reconcile_sessions(self) -> dict[str, Any]:
709
+ reconciled = []
710
+ for session in self.store.list_sessions():
711
+ if not active_state(session.state) or not session.tmux:
712
+ continue
713
+ timed_out = self._enforce_deadline(session)
714
+ if timed_out:
715
+ reconciled.append(session.id)
716
+ continue
717
+ if self.runtime.exists(session.tmux):
718
+ continue
719
+ self.store.update_session_state(session.id, SessionState.FAILED, ended_at=utc_now_iso())
720
+ self._event(
721
+ session,
722
+ "reconcile_dead_tmux",
723
+ state=SessionState.FAILED.value,
724
+ metadata={"tmux_session": session.tmux.session_name},
725
+ )
726
+ reconciled.append(session.id)
727
+ return {"reconciled": reconciled, "count": len(reconciled)}
728
+
729
+ def _require_session(self, session_id: str) -> AgentSession:
730
+ session = self.store.get_session(session_id)
731
+ if not session:
732
+ raise ToolError("TMUX_SESSION_NOT_FOUND", f"Session {session_id} was not found.", {"session_id": session_id})
733
+ return session
734
+
735
+ def _session_in_scope(self, session: AgentSession) -> bool:
736
+ if not self.scope_sessions_by_coordinator:
737
+ return True
738
+ return (session.metadata or {}).get("coordinator_id") == self.coordinator_id
739
+
740
+ def _event(
741
+ self,
742
+ session: AgentSession,
743
+ event_type: str,
744
+ state: str | None = None,
745
+ screen_hash: str | None = None,
746
+ excerpt: str | None = None,
747
+ metadata: dict[str, Any] | None = None,
748
+ ) -> int:
749
+ event_id = self.store.append_event(
750
+ session.id,
751
+ event_type,
752
+ state=state,
753
+ screen_hash=screen_hash,
754
+ excerpt=excerpt,
755
+ metadata=metadata,
756
+ )
757
+ append_jsonl(
758
+ Path(session.events_path),
759
+ {
760
+ "event_id": event_id,
761
+ "ts": utc_now_iso(),
762
+ "event_type": event_type,
763
+ "state": state,
764
+ "screen_hash": screen_hash,
765
+ "excerpt": excerpt,
766
+ "metadata": metadata or {},
767
+ },
768
+ )
769
+ return event_id
770
+
771
+ def _enforce_cached_usage_policy(self, provider_id: str) -> None:
772
+ snapshots = self.store.latest_usage_snapshots(provider_id)
773
+ if not snapshots:
774
+ return
775
+ snapshot = snapshots[0]
776
+ blocked = set(self.config.policy.block_on_usage_statuses)
777
+ status = snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status)
778
+ if status not in blocked:
779
+ return
780
+ raise ToolError(
781
+ "USAGE_POLICY_BLOCKED",
782
+ f"Provider {provider_id} is blocked by cached usage status: {status}.",
783
+ {
784
+ "provider_id": provider_id,
785
+ "status": status,
786
+ "policy": "block_on_usage_statuses",
787
+ "source": "sqlite_cache",
788
+ "checked_at": snapshot.checked_at.isoformat(),
789
+ },
790
+ )
791
+
792
+ def _configured_usage_snapshots(
793
+ self,
794
+ snapshots: list[Any],
795
+ provider_id: str | None = None,
796
+ ) -> list[Any]:
797
+ if provider_id:
798
+ return snapshots
799
+ configured = set(self.config.providers)
800
+ return [snapshot for snapshot in snapshots if snapshot.provider_id in configured]
801
+
802
+ def _enforce_deadline(self, session: AgentSession) -> ObserveWorkerResponse | None:
803
+ deadline_at = session.metadata.get("deadline_at")
804
+ if not deadline_at or not active_state(session.state):
805
+ return None
806
+ try:
807
+ deadline = datetime.fromisoformat(str(deadline_at))
808
+ except ValueError:
809
+ return None
810
+ if deadline.tzinfo is None:
811
+ deadline = deadline.replace(tzinfo=timezone.utc)
812
+ if datetime.now(timezone.utc) <= deadline:
813
+ return None
814
+ if session.tmux and self.runtime.exists(session.tmux):
815
+ self.runtime.terminate(session.tmux)
816
+ session.state = SessionState.CANCELLED
817
+ session.ended_at = datetime.now(timezone.utc)
818
+ self.store.update_session_state(session.id, SessionState.CANCELLED, ended_at=session.ended_at.isoformat())
819
+ self._event(session, "timeout", state=SessionState.CANCELLED.value, metadata={"deadline_at": str(deadline_at)})
820
+ return ObserveWorkerResponse(
821
+ session_id=session.id,
822
+ state=SessionState.CANCELLED,
823
+ event=ObserveEvent.TIMEOUT,
824
+ confidence="observed",
825
+ metadata={"deadline_at": str(deadline_at)},
826
+ )
827
+
828
+ def _enforce_deadline_or_raise(self, session: AgentSession) -> None:
829
+ timed_out = self._enforce_deadline(session)
830
+ if timed_out:
831
+ raise ToolError(
832
+ "SESSION_TIMEOUT",
833
+ "Session exceeded max_runtime_seconds and was terminated.",
834
+ {"session_id": session.id, **timed_out.metadata},
835
+ )
836
+
837
+ def _enforce_turn_limit(self, session: AgentSession) -> None:
838
+ max_turns = session.metadata.get("max_turns")
839
+ if max_turns is None:
840
+ return
841
+ turns_sent = int(session.metadata.get("turns_sent") or 0)
842
+ if turns_sent < int(max_turns):
843
+ return
844
+ raise ToolError(
845
+ "TURN_LIMIT_REACHED",
846
+ "Session reached max_turns.",
847
+ {"session_id": session.id, "max_turns": max_turns, "turns_sent": turns_sent},
848
+ )
849
+
850
+ def _rollback_spawn_files(
851
+ self,
852
+ repo_path: Path,
853
+ worktree_path: str | None,
854
+ provider_id: str,
855
+ session_id: str,
856
+ artifact_dir: Path | None,
857
+ ) -> None:
858
+ if worktree_path:
859
+ try:
860
+ cleanup_worktree(repo_path, Path(worktree_path), force=True)
861
+ except ToolError:
862
+ pass
863
+ delete_agentpool_branch(repo_path, provider_id, session_id)
864
+ if artifact_dir and artifact_dir.exists():
865
+ shutil.rmtree(artifact_dir, ignore_errors=True)
866
+
867
+
868
+ def _tmux_name(prefix: str, provider_id: str, session_id: str) -> str:
869
+ safe_provider = "".join(ch if ch.isalnum() else "-" for ch in provider_id).strip("-")
870
+ return f"{prefix}-{safe_provider}-{session_id[-6:]}"
871
+
872
+
873
+ def _default_model(metadata: dict[str, Any]) -> str | None:
874
+ value = metadata.get("default_model")
875
+ return str(value) if value else None
876
+
877
+
878
+ def _normalize_state_filter(states: list[str] | str | None) -> list[str] | None:
879
+ if states is None:
880
+ return None
881
+ raw_states = [states] if isinstance(states, str) else list(states)
882
+ normalized: list[str] = []
883
+ for state in raw_states:
884
+ value = str(state)
885
+ try:
886
+ normalized.append(SessionState(value).value)
887
+ continue
888
+ except ValueError:
889
+ pass
890
+ upper = value.upper()
891
+ try:
892
+ normalized.append(SessionState(upper).value)
893
+ except ValueError:
894
+ normalized.append(value)
895
+ return normalized
896
+
897
+
898
+ def _normalize_session_limit(limit: int | None) -> int | None:
899
+ if limit is None:
900
+ return None
901
+ if limit <= 0 or limit > MAX_SESSION_LIMIT:
902
+ raise ToolError(
903
+ "INVALID_SESSION_PAGE",
904
+ f"limit must be between 1 and {MAX_SESSION_LIMIT}.",
905
+ {"limit": limit, "max_limit": MAX_SESSION_LIMIT},
906
+ )
907
+ return limit
908
+
909
+
910
+ def _session_summary(session: AgentSession) -> dict[str, Any]:
911
+ metadata = session.metadata or {}
912
+ return {
913
+ "id": session.id,
914
+ "provider_id": session.provider_id,
915
+ "state": session.state.value if hasattr(session.state, "value") else session.state,
916
+ "created_at": session.created_at.isoformat(),
917
+ "deadline_at": metadata.get("deadline_at"),
918
+ "tmux_session": session.tmux.session_name if session.tmux else None,
919
+ }
920
+
921
+
922
+ def _default_reasoning_effort(
923
+ provider_metadata: dict[str, Any],
924
+ models: list[dict[str, Any]],
925
+ model: str | None,
926
+ ) -> str | None:
927
+ if not model or not provider_metadata.get("reasoning_effort_config_key"):
928
+ return None
929
+ for entry in models:
930
+ if not isinstance(entry, dict) or entry.get("id") != model:
931
+ continue
932
+ model_metadata = entry.get("metadata") or {}
933
+ reasoning = model_metadata.get("reasoning") if isinstance(model_metadata, dict) else None
934
+ if isinstance(reasoning, dict) and reasoning.get("default"):
935
+ return str(reasoning["default"])
936
+ return None
937
+
938
+
939
+ def _effective_initial_prompt_mode(requested: str, metadata: dict[str, Any]) -> str:
940
+ if requested != "provider_default":
941
+ return requested
942
+ configured = metadata.get("default_initial_prompt_mode")
943
+ if configured in {"send_after_launch", "arg", "stdin"}:
944
+ return str(configured)
945
+ return "send_after_launch"
946
+
947
+
948
+ def _alias_event(event: str) -> str:
949
+ return {"approval_prompt": "approval"}.get(event, event)
950
+
951
+
952
+ def _looks_like_menu_choice(message: str) -> bool:
953
+ stripped = message.strip()
954
+ return bool(stripped and len(stripped) <= 3 and stripped.isalnum())
955
+
956
+
957
+ def _classify_readiness(
958
+ session: AgentSession,
959
+ detection: Any,
960
+ previous_hash: str | None,
961
+ current_hash: str,
962
+ screen: str,
963
+ ) -> str:
964
+ if detection.event == ObserveEvent.COMPLETED:
965
+ return "completed"
966
+ if detection.event == ObserveEvent.ERROR:
967
+ return "failed"
968
+ if detection.event == ObserveEvent.QUESTION:
969
+ return "waiting_on_question"
970
+ if detection.event == ObserveEvent.OVERAGE_PROMPT:
971
+ return "waiting_on_overage_prompt"
972
+ if detection.event == ObserveEvent.APPROVAL_PROMPT:
973
+ return "waiting_on_startup_prompt" if _looks_like_startup_prompt(screen) else "waiting_on_approval"
974
+ if detection.event == ObserveEvent.LIMIT_WARNING:
975
+ return "running_limit_warning"
976
+ if previous_hash and previous_hash == current_hash:
977
+ return "stuck_unchanged_screen"
978
+ if session.state == SessionState.READY and session.metadata.get("initial_prompt_mode") == "send_after_launch":
979
+ return "pasted_but_not_submitted"
980
+ return "running"
981
+
982
+
983
+ def _looks_like_startup_prompt(screen: str) -> bool:
984
+ lowered = screen.lower()
985
+ return any(
986
+ phrase in lowered
987
+ for phrase in [
988
+ "update available",
989
+ "do you trust the contents of this directory",
990
+ "hooks need review",
991
+ "mcp startup",
992
+ "mcp client for",
993
+ ]
994
+ )
995
+
996
+
997
+ def _startup_warning_summary(screen: str) -> list[str]:
998
+ warnings = []
999
+ lowered = screen.lower()
1000
+ if "update available" in lowered:
1001
+ warnings.append("update_available")
1002
+ if "mcp client for" in lowered or "mcp startup" in lowered:
1003
+ warnings.append("mcp_startup_warning")
1004
+ if "do you trust the contents of this directory" in lowered:
1005
+ warnings.append("directory_trust_prompt")
1006
+ if "hooks need review" in lowered:
1007
+ warnings.append("hooks_need_review")
1008
+ return warnings
1009
+
1010
+
1011
+ def _normalize_file_path(session: AgentSession, file_path: str) -> str:
1012
+ raw = Path(file_path).expanduser()
1013
+ if not raw.is_absolute():
1014
+ return raw.as_posix()
1015
+ for base in [session.worktree_path, session.repo_path]:
1016
+ if not base:
1017
+ continue
1018
+ try:
1019
+ return raw.resolve().relative_to(Path(base).expanduser().resolve()).as_posix()
1020
+ except ValueError:
1021
+ continue
1022
+ return raw.as_posix()
1023
+
1024
+
1025
+ def _transcript_payload(
1026
+ session: AgentSession,
1027
+ path: Path,
1028
+ text: str,
1029
+ *,
1030
+ offset: int | None,
1031
+ next_offset: int | None,
1032
+ size_bytes: int,
1033
+ mode: str,
1034
+ limit: int | None = None,
1035
+ tail_lines: int | None = None,
1036
+ has_more: bool = False,
1037
+ ) -> dict[str, Any]:
1038
+ return {
1039
+ "session_id": session.id,
1040
+ "path": str(path),
1041
+ "exists": path.exists(),
1042
+ "mode": mode,
1043
+ "offset": offset,
1044
+ "limit": limit,
1045
+ "tail_lines": tail_lines,
1046
+ "next_offset": next_offset,
1047
+ "has_more": has_more,
1048
+ "size_bytes": size_bytes,
1049
+ "text": text,
1050
+ }
1051
+
1052
+
1053
+ def manager_from_config(path: Path | None = None) -> SessionManager:
1054
+ config = load_config(path)
1055
+ return SessionManager(config=config)
1056
+
1057
+
1058
+ def write_default_config(path: Path) -> None:
1059
+ config = load_config(Path("__missing_agentpool_config__.yaml"))
1060
+ path.parent.mkdir(parents=True, exist_ok=True)
1061
+ write_json(path, config.model_dump(mode="json"))