delimit-cli 3.14.28 → 3.14.29

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 (47) hide show
  1. package/gateway/ai/backends/deploy_bridge.py +56 -2
  2. package/gateway/ai/backends/gateway_core.py +212 -1
  3. package/gateway/ai/backends/generate_bridge.py +84 -13
  4. package/gateway/ai/backends/governance_bridge.py +63 -16
  5. package/gateway/ai/backends/memory_bridge.py +77 -76
  6. package/gateway/ai/backends/ops_bridge.py +76 -6
  7. package/gateway/ai/backends/os_bridge.py +23 -3
  8. package/gateway/ai/backends/repo_bridge.py +156 -17
  9. package/gateway/ai/backends/tools_design.py +116 -9
  10. package/gateway/ai/backends/tools_infra.py +200 -72
  11. package/gateway/ai/backends/tools_real.py +8 -0
  12. package/gateway/ai/backends/ui_bridge.py +115 -5
  13. package/gateway/ai/backends/vault_bridge.py +69 -114
  14. package/gateway/ai/content_engine.py +1276 -0
  15. package/gateway/ai/context_fs.py +193 -0
  16. package/gateway/ai/daemon.py +500 -0
  17. package/gateway/ai/data_plane.py +291 -0
  18. package/gateway/ai/deliberation.py +1033 -6
  19. package/gateway/ai/events.py +39 -0
  20. package/gateway/ai/founding_users.py +162 -0
  21. package/gateway/ai/governance.py +698 -4
  22. package/gateway/ai/inbox_daemon.py +78 -17
  23. package/gateway/ai/integrations/__init__.py +1 -0
  24. package/gateway/ai/integrations/opensage_wrapper.py +288 -0
  25. package/gateway/ai/key_resolver.py +95 -0
  26. package/gateway/ai/ledger_manager.py +289 -1
  27. package/gateway/ai/license.py +62 -4
  28. package/gateway/ai/license_core.py +208 -7
  29. package/gateway/ai/local_server.py +215 -0
  30. package/gateway/ai/loop_engine.py +408 -0
  31. package/gateway/ai/mcp_bridge.py +178 -0
  32. package/gateway/ai/release_sync.py +2 -2
  33. package/gateway/ai/screen_record.py +374 -0
  34. package/gateway/ai/secrets_broker.py +235 -0
  35. package/gateway/ai/social.py +189 -27
  36. package/gateway/ai/social_target.py +1635 -0
  37. package/gateway/ai/supabase_sync.py +190 -0
  38. package/gateway/ai/tracing.py +195 -0
  39. package/gateway/core/contract_ledger.py +1 -1
  40. package/gateway/core/dependency_graph.py +1 -1
  41. package/gateway/core/dependency_manifest.py +1 -1
  42. package/gateway/core/diff_engine_v2.py +272 -78
  43. package/gateway/core/event_backbone.py +2 -2
  44. package/gateway/core/event_schema.py +1 -1
  45. package/gateway/core/impact_analyzer.py +1 -1
  46. package/gateway/core/policy_engine.py +4 -0
  47. package/package.json +1 -1
@@ -0,0 +1,215 @@
1
+ """Local API server — serves ~/.delimit/ data over HTTP for dashboard access.
2
+
3
+ Runs on localhost:7823. The dashboard connects here to show local data.
4
+ No auth needed (localhost only). CORS enabled for app.delimit.ai.
5
+ """
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from http.server import HTTPServer, BaseHTTPRequestHandler
10
+
11
+ def _get_delimit_home() -> Path:
12
+ return Path(os.environ.get("DELIMIT_HOME", str(Path.home() / ".delimit")))
13
+
14
+ DELIMIT_HOME = _get_delimit_home() # Default, can be overridden
15
+ PORT = int(os.environ.get("DELIMIT_LOCAL_PORT", 7823))
16
+
17
+ ALLOWED_ORIGINS = {
18
+ "https://app.delimit.ai",
19
+ "http://localhost:3000",
20
+ "http://localhost:3001",
21
+ }
22
+
23
+
24
+ class DelimitHandler(BaseHTTPRequestHandler):
25
+ """HTTP handler for the local Delimit API."""
26
+
27
+ def _get_origin(self):
28
+ origin = self.headers.get("Origin", "")
29
+ if origin in ALLOWED_ORIGINS:
30
+ return origin
31
+ return ""
32
+
33
+ def _send_cors_headers(self):
34
+ origin = self._get_origin()
35
+ if origin:
36
+ self.send_header("Access-Control-Allow-Origin", origin)
37
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
38
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
39
+ self.send_header("Vary", "Origin")
40
+
41
+ def do_GET(self):
42
+ path = self.path.split("?")[0]
43
+
44
+ routes = {
45
+ "/api/health": self.handle_health,
46
+ "/api/ledger": self.handle_ledger,
47
+ "/api/events": self.handle_events,
48
+ "/api/governance": self.handle_governance,
49
+ "/api/daemon": self.handle_daemon,
50
+ "/api/sessions": self.handle_sessions,
51
+ }
52
+
53
+ handler = routes.get(path)
54
+ if handler:
55
+ try:
56
+ data = handler()
57
+ self.send_response(200)
58
+ self.send_header("Content-Type", "application/json")
59
+ self._send_cors_headers()
60
+ self.end_headers()
61
+ self.wfile.write(json.dumps(data).encode())
62
+ except Exception as e:
63
+ self.send_response(500)
64
+ self.send_header("Content-Type", "application/json")
65
+ self._send_cors_headers()
66
+ self.end_headers()
67
+ self.wfile.write(json.dumps({"error": str(e)}).encode())
68
+ else:
69
+ self.send_response(404)
70
+ self.send_header("Content-Type", "application/json")
71
+ self._send_cors_headers()
72
+ self.end_headers()
73
+ self.wfile.write(json.dumps({"error": "Not found"}).encode())
74
+
75
+ def do_OPTIONS(self):
76
+ self.send_response(200)
77
+ self._send_cors_headers()
78
+ self.end_headers()
79
+
80
+ def log_message(self, format, *args):
81
+ """Suppress default stderr logging."""
82
+ pass
83
+
84
+ # ── Route handlers ──────────────────────────────────────────────
85
+
86
+ @staticmethod
87
+ def _home() -> Path:
88
+ """Read DELIMIT_HOME dynamically for testability."""
89
+ return Path(os.environ.get("DELIMIT_HOME", str(Path.home() / ".delimit")))
90
+
91
+ def handle_health(self):
92
+ return {
93
+ "status": "ok",
94
+ "port": PORT,
95
+ "home": str(self._home()),
96
+ }
97
+
98
+ def handle_ledger(self):
99
+ items = []
100
+ for fname in ["operations.jsonl", "strategy.jsonl"]:
101
+ fpath = self._home() / "ledger" / fname
102
+ if not fpath.exists():
103
+ continue
104
+ latest = {}
105
+ for line in fpath.read_text().splitlines():
106
+ line = line.strip()
107
+ if not line:
108
+ continue
109
+ try:
110
+ entry = json.loads(line)
111
+ entry_id = entry.get("id")
112
+ if not entry_id:
113
+ continue
114
+ if entry.get("type") == "update" and entry_id in latest:
115
+ latest[entry_id].update(entry)
116
+ elif entry.get("type") != "update":
117
+ latest[entry_id] = entry
118
+ except (json.JSONDecodeError, TypeError):
119
+ pass
120
+ items.extend(latest.values())
121
+
122
+ open_items = [i for i in items if i.get("status") == "open"]
123
+ done_items = [i for i in items if i.get("status") == "done"]
124
+ return {
125
+ "items": items,
126
+ "summary": {
127
+ "total": len(items),
128
+ "open": len(open_items),
129
+ "done": len(done_items),
130
+ },
131
+ }
132
+
133
+ def handle_events(self):
134
+ events_dir = self._home() / "events"
135
+ if not events_dir.exists():
136
+ return {"events": []}
137
+ events = []
138
+ for f in sorted(events_dir.glob("events-*.jsonl"), reverse=True):
139
+ for line in reversed(f.read_text().splitlines()):
140
+ line = line.strip()
141
+ if not line:
142
+ continue
143
+ try:
144
+ events.append(json.loads(line))
145
+ except (json.JSONDecodeError, TypeError):
146
+ pass
147
+ if len(events) >= 50:
148
+ break
149
+ if len(events) >= 50:
150
+ break
151
+ return {"events": events}
152
+
153
+ def handle_governance(self):
154
+ gov_dir = self._home() / "governance"
155
+ if not gov_dir.exists():
156
+ return {"checks": [], "overall": "unknown"}
157
+ checks = []
158
+ for f in gov_dir.glob("*.json"):
159
+ try:
160
+ checks.append(json.loads(f.read_text()))
161
+ except (json.JSONDecodeError, TypeError):
162
+ pass
163
+ overall = "pass" if checks and all(
164
+ c.get("status") == "pass" for c in checks
165
+ ) else "warn"
166
+ return {"checks": checks, "overall": overall}
167
+
168
+ def handle_daemon(self):
169
+ state_file = self._home() / "daemon" / "state.json"
170
+ if state_file.exists():
171
+ try:
172
+ return json.loads(state_file.read_text())
173
+ except (json.JSONDecodeError, TypeError):
174
+ pass
175
+ return {"status": "not_running", "loops": 0}
176
+
177
+ def handle_sessions(self):
178
+ sessions_dir = self._home() / "sessions"
179
+ if not sessions_dir.exists():
180
+ return {"sessions": []}
181
+ sessions = []
182
+ for f in sessions_dir.glob("*.json"):
183
+ try:
184
+ sessions.append(json.loads(f.read_text()))
185
+ except (json.JSONDecodeError, TypeError):
186
+ pass
187
+ return {"sessions": sessions}
188
+
189
+
190
+ def start_server(port=None, background=True):
191
+ """Start the local API server.
192
+
193
+ Args:
194
+ port: Port to bind (default: DELIMIT_LOCAL_PORT env or 7823).
195
+ background: If True, run in a daemon thread and return the server.
196
+ If False, block forever with serve_forever().
197
+
198
+ Returns:
199
+ The HTTPServer instance (background=True) or never returns (background=False).
200
+ """
201
+ if port is None:
202
+ port = PORT
203
+ server = HTTPServer(("127.0.0.1", port), DelimitHandler)
204
+ print(f"Delimit local server running on http://localhost:{port}")
205
+ if background:
206
+ import threading
207
+ t = threading.Thread(target=server.serve_forever, daemon=True)
208
+ t.start()
209
+ return server
210
+ else:
211
+ server.serve_forever()
212
+
213
+
214
+ if __name__ == "__main__":
215
+ start_server(background=False)
@@ -0,0 +1,408 @@
1
+ """Autonomous build loop engine — governed, throttled, cross-model.
2
+
3
+ Provides the core loop primitives that any AI model can use via MCP:
4
+ - next_task: get the next prioritized item with safeguard checks
5
+ - task_complete: record completion, check if loop should continue
6
+ - loop_status: current session metrics
7
+ - loop_config: configure safeguards
8
+
9
+ Session state persisted at ~/.delimit/loop/sessions/<session_id>.json
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ LOOP_DIR = Path.home() / ".delimit" / "loop"
20
+ SESSIONS_DIR = LOOP_DIR / "sessions"
21
+
22
+ # Actions the AI model must never auto-execute without human approval
23
+ DEFAULT_REQUIRE_APPROVAL = ["deploy", "social_post", "outreach", "publish"]
24
+
25
+ VALID_STATUSES = {"running", "paused", "stopped", "circuit_broken"}
26
+
27
+
28
+ def _ensure_dir():
29
+ """Create the loop sessions directory if it doesn't exist."""
30
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
31
+
32
+
33
+ def _session_path(session_id: str) -> Path:
34
+ """Return the filesystem path for a session state file."""
35
+ return SESSIONS_DIR / f"{session_id}.json"
36
+
37
+
38
+ def _load_session(session_id: str) -> Optional[Dict[str, Any]]:
39
+ """Load session state from disk. Returns None if not found."""
40
+ path = _session_path(session_id)
41
+ if not path.exists():
42
+ return None
43
+ try:
44
+ return json.loads(path.read_text())
45
+ except (json.JSONDecodeError, OSError):
46
+ return None
47
+
48
+
49
+ def _save_session(session: Dict[str, Any]):
50
+ """Persist session state to disk."""
51
+ _ensure_dir()
52
+ path = _session_path(session["session_id"])
53
+ path.write_text(json.dumps(session, indent=2))
54
+
55
+
56
+ def _create_session(session_id: str = "") -> Dict[str, Any]:
57
+ """Create a new loop session with default safeguards."""
58
+ if not session_id:
59
+ session_id = str(uuid.uuid4())[:12]
60
+ session = {
61
+ "session_id": session_id,
62
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
63
+ "iterations": 0,
64
+ "max_iterations": 50,
65
+ "cost_incurred": 0.0,
66
+ "cost_cap": 5.0,
67
+ "errors": 0,
68
+ "error_threshold": 3,
69
+ "tasks_completed": [],
70
+ "auto_consensus": False,
71
+ "require_approval_for": list(DEFAULT_REQUIRE_APPROVAL),
72
+ "status": "running",
73
+ }
74
+ _save_session(session)
75
+ return session
76
+
77
+
78
+ def _get_or_create_session(session_id: str = "") -> Dict[str, Any]:
79
+ """Load an existing session or create a new one."""
80
+ if session_id:
81
+ existing = _load_session(session_id)
82
+ if existing:
83
+ return existing
84
+ return _create_session(session_id)
85
+
86
+
87
+ def _check_safeguards(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
88
+ """Check all safeguards. Returns a STOP action if any are tripped, else None."""
89
+ if session.get("status") == "paused":
90
+ return {
91
+ "action": "STOP",
92
+ "reason": "Loop is paused. Call loop_config to resume.",
93
+ "safeguard": "paused",
94
+ }
95
+
96
+ if session.get("status") == "stopped":
97
+ return {
98
+ "action": "STOP",
99
+ "reason": "Loop has been stopped.",
100
+ "safeguard": "stopped",
101
+ }
102
+
103
+ if session.get("status") == "circuit_broken":
104
+ return {
105
+ "action": "STOP",
106
+ "reason": f"Circuit breaker tripped after {session['errors']} errors.",
107
+ "safeguard": "circuit_breaker",
108
+ }
109
+
110
+ if session["iterations"] >= session["max_iterations"]:
111
+ return {
112
+ "action": "STOP",
113
+ "reason": f"Reached max iterations ({session['max_iterations']}).",
114
+ "safeguard": "max_iterations",
115
+ }
116
+
117
+ if session["cost_incurred"] >= session["cost_cap"]:
118
+ return {
119
+ "action": "STOP",
120
+ "reason": f"Cost cap reached (${session['cost_incurred']:.2f} >= ${session['cost_cap']:.2f}).",
121
+ "safeguard": "cost_cap",
122
+ }
123
+
124
+ if session["errors"] >= session["error_threshold"]:
125
+ session["status"] = "circuit_broken"
126
+ _save_session(session)
127
+ return {
128
+ "action": "STOP",
129
+ "reason": f"Circuit breaker: {session['errors']} errors hit threshold ({session['error_threshold']}).",
130
+ "safeguard": "circuit_breaker",
131
+ }
132
+
133
+ return None
134
+
135
+
136
+ def _get_open_items(venture: str = "", project_path: str = ".") -> List[Dict[str, Any]]:
137
+ """Query the ledger for open items, sorted by priority."""
138
+ from ai.ledger_manager import list_items
139
+ result = list_items(status="open", project_path=project_path)
140
+ items = []
141
+ for ledger_items in result.get("items", {}).values():
142
+ items.extend(ledger_items)
143
+
144
+ # Sort by priority
145
+ priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
146
+ items.sort(key=lambda x: priority_order.get(x.get("priority", "P2"), 9))
147
+ return items
148
+
149
+
150
+ def _filter_actionable(items: List[Dict[str, Any]], max_risk: str = "") -> List[Dict[str, Any]]:
151
+ """Filter out owner-only items and apply risk filtering.
152
+
153
+ Owner-only items are those with source='owner' or tags containing 'owner-action'.
154
+ """
155
+ filtered = []
156
+ for item in items:
157
+ # Skip owner-only items
158
+ tags = item.get("tags", [])
159
+ if "owner-action" in tags or "owner-only" in tags:
160
+ continue
161
+ if item.get("source") == "owner":
162
+ continue
163
+
164
+ # Risk filtering
165
+ if max_risk:
166
+ item_risk = item.get("risk", "")
167
+ if item_risk and _risk_level(item_risk) > _risk_level(max_risk):
168
+ continue
169
+
170
+ filtered.append(item)
171
+ return filtered
172
+
173
+
174
+ def _resolve_project_path(venture: str) -> str:
175
+ """Resolve a venture name or path to a project directory path."""
176
+ if not venture:
177
+ return "."
178
+ # Direct path — use as-is
179
+ if venture.startswith("/") or venture.startswith("~"):
180
+ return str(Path(venture).expanduser())
181
+ if venture.startswith(".") or os.sep in venture:
182
+ return str(Path(venture).resolve())
183
+ # Try registered ventures
184
+ try:
185
+ from ai.ledger_manager import list_ventures
186
+ ventures = list_ventures()
187
+ for name, info in ventures.get("ventures", {}).items():
188
+ if name == venture or venture in name:
189
+ return info.get("path", ".")
190
+ except Exception:
191
+ pass
192
+ return "."
193
+
194
+
195
+ def _risk_level(risk: str) -> int:
196
+ """Convert risk string to numeric level for comparison."""
197
+ levels = {"low": 1, "medium": 2, "high": 3, "critical": 4}
198
+ return levels.get(risk.lower(), 2)
199
+
200
+
201
+ def next_task(
202
+ venture: str = "",
203
+ max_risk: str = "",
204
+ session_id: str = "",
205
+ ) -> Dict[str, Any]:
206
+ """Get the next task to work on with safeguard checks.
207
+
208
+ Returns:
209
+ Dict with action: BUILD (with task), CONSENSUS (generate new items), or STOP.
210
+ """
211
+ session = _get_or_create_session(session_id)
212
+
213
+ # Check safeguards
214
+ stop = _check_safeguards(session)
215
+ if stop:
216
+ stop["session"] = _session_summary(session)
217
+ return stop
218
+
219
+ # Resolve venture path
220
+ project_path = _resolve_project_path(venture)
221
+
222
+ # Get open items
223
+ items = _get_open_items(venture=venture, project_path=project_path)
224
+ actionable = _filter_actionable(items, max_risk=max_risk)
225
+
226
+ if not actionable:
227
+ if session.get("auto_consensus"):
228
+ return {
229
+ "action": "CONSENSUS",
230
+ "message": "No actionable items. Run consensus to generate new work.",
231
+ "session": _session_summary(session),
232
+ }
233
+ return {
234
+ "action": "STOP",
235
+ "reason": "No actionable items in the ledger.",
236
+ "safeguard": "empty_ledger",
237
+ "session": _session_summary(session),
238
+ }
239
+
240
+ task = actionable[0]
241
+
242
+ # Check if this task requires approval
243
+ require_approval = session.get("require_approval_for", [])
244
+ task_tags = task.get("tags", [])
245
+ needs_approval = any(tag in require_approval for tag in task_tags)
246
+ task_type = task.get("type", "")
247
+ if task_type in require_approval:
248
+ needs_approval = True
249
+
250
+ result = {
251
+ "action": "BUILD",
252
+ "task": task,
253
+ "remaining_items": len(actionable) - 1,
254
+ "session": _session_summary(session),
255
+ }
256
+ if needs_approval:
257
+ result["approval_required"] = True
258
+ result["approval_reason"] = "Task type or tags match require_approval_for list."
259
+
260
+ return result
261
+
262
+
263
+ def task_complete(
264
+ task_id: str,
265
+ result: str = "",
266
+ cost_incurred: float = 0.0,
267
+ error: str = "",
268
+ session_id: str = "",
269
+ venture: str = "",
270
+ ) -> Dict[str, Any]:
271
+ """Mark current task done and get the next one.
272
+
273
+ Records completion, updates session metrics, returns the next task.
274
+ """
275
+ session = _get_or_create_session(session_id)
276
+
277
+ # Update metrics
278
+ session["iterations"] += 1
279
+ session["cost_incurred"] += cost_incurred
280
+
281
+ if error:
282
+ session["errors"] += 1
283
+ session["tasks_completed"].append({
284
+ "task_id": task_id,
285
+ "status": "error",
286
+ "error": error,
287
+ "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
288
+ "cost": cost_incurred,
289
+ })
290
+ else:
291
+ session["tasks_completed"].append({
292
+ "task_id": task_id,
293
+ "status": "done",
294
+ "result": result[:500] if result else "",
295
+ "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
296
+ "cost": cost_incurred,
297
+ })
298
+
299
+ _save_session(session)
300
+
301
+ # Mark ledger item as done (best-effort)
302
+ if not error:
303
+ try:
304
+ from ai.ledger_manager import update_item
305
+ project_path = _resolve_project_path(venture)
306
+ update_item(item_id=task_id, status="done", note=result[:200] if result else "Completed via build loop", project_path=project_path)
307
+ except Exception:
308
+ pass # Never let ledger sync break the loop
309
+
310
+ # Return the next task
311
+ return next_task(venture=venture, session_id=session["session_id"])
312
+
313
+
314
+ def loop_status(session_id: str = "") -> Dict[str, Any]:
315
+ """Return current session metrics."""
316
+ if not session_id:
317
+ # Try to find the most recent session
318
+ sessions = _list_sessions()
319
+ if not sessions:
320
+ return {"error": "No active loop sessions found."}
321
+ session_id = sessions[0]["session_id"]
322
+
323
+ session = _load_session(session_id)
324
+ if not session:
325
+ return {"error": f"Session {session_id} not found."}
326
+
327
+ return {
328
+ "session": _session_summary(session),
329
+ "tasks_completed": session.get("tasks_completed", []),
330
+ "safeguards": {
331
+ "max_iterations": session["max_iterations"],
332
+ "cost_cap": session["cost_cap"],
333
+ "error_threshold": session["error_threshold"],
334
+ "require_approval_for": session.get("require_approval_for", []),
335
+ },
336
+ }
337
+
338
+
339
+ def loop_config(
340
+ session_id: str = "",
341
+ max_iterations: int = 0,
342
+ cost_cap: float = 0.0,
343
+ auto_consensus: Optional[bool] = None,
344
+ error_threshold: int = 0,
345
+ status: str = "",
346
+ require_approval_for: Optional[List[str]] = None,
347
+ ) -> Dict[str, Any]:
348
+ """Update session configuration. Only provided values are changed."""
349
+ session = _get_or_create_session(session_id)
350
+
351
+ changes = {}
352
+ if max_iterations > 0:
353
+ session["max_iterations"] = max_iterations
354
+ changes["max_iterations"] = max_iterations
355
+ if cost_cap > 0:
356
+ session["cost_cap"] = cost_cap
357
+ changes["cost_cap"] = cost_cap
358
+ if auto_consensus is not None:
359
+ session["auto_consensus"] = auto_consensus
360
+ changes["auto_consensus"] = auto_consensus
361
+ if error_threshold > 0:
362
+ session["error_threshold"] = error_threshold
363
+ changes["error_threshold"] = error_threshold
364
+ if status and status in VALID_STATUSES:
365
+ session["status"] = status
366
+ changes["status"] = status
367
+ if require_approval_for is not None:
368
+ session["require_approval_for"] = require_approval_for
369
+ changes["require_approval_for"] = require_approval_for
370
+
371
+ _save_session(session)
372
+
373
+ return {
374
+ "session_id": session["session_id"],
375
+ "changes": changes,
376
+ "current_config": _session_summary(session),
377
+ }
378
+
379
+
380
+ def _session_summary(session: Dict[str, Any]) -> Dict[str, Any]:
381
+ """Return a concise session summary for inclusion in responses."""
382
+ return {
383
+ "session_id": session["session_id"],
384
+ "status": session["status"],
385
+ "iterations": session["iterations"],
386
+ "max_iterations": session["max_iterations"],
387
+ "cost_incurred": round(session["cost_incurred"], 4),
388
+ "cost_cap": session["cost_cap"],
389
+ "errors": session["errors"],
390
+ "error_threshold": session["error_threshold"],
391
+ "tasks_done": len(session.get("tasks_completed", [])),
392
+ "auto_consensus": session.get("auto_consensus", False),
393
+ }
394
+
395
+
396
+ def _list_sessions() -> List[Dict[str, Any]]:
397
+ """List all sessions, most recent first."""
398
+ if not SESSIONS_DIR.exists():
399
+ return []
400
+ sessions = []
401
+ for f in SESSIONS_DIR.glob("*.json"):
402
+ try:
403
+ s = json.loads(f.read_text())
404
+ sessions.append(s)
405
+ except (json.JSONDecodeError, OSError):
406
+ continue
407
+ sessions.sort(key=lambda x: x.get("started_at", ""), reverse=True)
408
+ return sessions