claude-finops 0.1.0

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.
@@ -0,0 +1,499 @@
1
+ """Running agent sessions (Claude Code, Codex, Gemini CLI, Cursor) and the actions the dashboard can take on them.
2
+
3
+ Source of truth is ~/.claude/sessions/<pid>.json (written by Claude Code), checked
4
+ against the live process table. A signal is only ever sent to a PID that, at the
5
+ moment of the request, is alive, runs `claude`, and has the same start time as the
6
+ registry entry — so a recycled PID can never be hit.
7
+ """
8
+ import glob
9
+ import json
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import sys
14
+ import subprocess
15
+ import time
16
+
17
+ HOME = os.path.expanduser("~")
18
+ REG = os.path.join(HOME, ".claude", "sessions")
19
+ PROJECTS = os.path.join(HOME, ".claude", "projects")
20
+
21
+ ACTIONS = {
22
+ "interrupt": (getattr(signal, "CTRL_C_EVENT", signal.SIGINT) if os.name == "nt" else signal.SIGINT, "Interrupted the current turn (like pressing Esc/Ctrl-C)."),
23
+ "close": (signal.SIGTERM, "Asked the session to exit. Resume it later with `claude --resume`."),
24
+ "kill": (getattr(signal, "SIGKILL", signal.SIGTERM), "Force-killed the process."),
25
+ }
26
+
27
+
28
+ def _ps():
29
+ """pid -> {ppid, lstart, command, rss_kb, etime}"""
30
+ if os.name == "nt":
31
+ return _ps_windows()
32
+ out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss=,etime=,lstart=,command="],
33
+ capture_output=True, text=True,
34
+ env=dict(os.environ, TZ="UTC", LC_ALL="C")).stdout # registry is UTC
35
+ procs = {}
36
+ for line in out.splitlines():
37
+ parts = line.split(None, 9)
38
+ if len(parts) < 10:
39
+ continue
40
+ pid, ppid, rss, etime = parts[:4]
41
+ lstart = " ".join(parts[4:9])
42
+ procs[int(pid)] = {"ppid": int(ppid), "rss_kb": int(rss), "etime": etime,
43
+ "lstart": lstart, "command": parts[9]}
44
+ return procs
45
+
46
+
47
+ def _ps_windows():
48
+ """Windows has no `ps`; ask CIM for the same fields (lstart/etime unavailable -> blank)."""
49
+ ps = ("Get-CimInstance Win32_Process | ForEach-Object { "
50
+ "\"$($_.ProcessId)`t$($_.ParentProcessId)`t$([int]($_.WorkingSetSize/1024))`t$($_.CommandLine)\" }")
51
+ try:
52
+ out = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
53
+ capture_output=True, text=True, timeout=20).stdout
54
+ except (OSError, subprocess.TimeoutExpired):
55
+ return {}
56
+ procs = {}
57
+ for line in out.splitlines():
58
+ parts = line.split("\t", 3)
59
+ if len(parts) < 4 or not parts[0].isdigit():
60
+ continue
61
+ procs[int(parts[0])] = {"ppid": int(parts[1] or 0), "rss_kb": int(parts[2] or 0),
62
+ "etime": "", "lstart": "", "command": parts[3]}
63
+ return procs
64
+
65
+
66
+ def _is_claude(p):
67
+ cmd = p["command"].split()[0].strip('"') if p["command"] else ""
68
+ base = os.path.basename(cmd.replace("\\", "/")).lower()
69
+ return base in ("claude", "claude.exe", "claude.cmd") or cmd.endswith("/claude")
70
+
71
+
72
+ def _ancestors(pid, procs):
73
+ seen = set()
74
+ while pid in procs and pid not in seen and pid > 1:
75
+ seen.add(pid)
76
+ pid = procs[pid]["ppid"]
77
+ return seen
78
+
79
+
80
+ def _transcript_stats(session_id, pricing):
81
+ files = glob.glob(os.path.join(PROJECTS, "*", f"{session_id}.jsonl"))
82
+ if not files:
83
+ return {}
84
+ fp = files[0]
85
+ steps = tokens = 0
86
+ ctx = None
87
+ cost = 0.0
88
+ with open(fp, errors="replace") as fh:
89
+ for line in fh:
90
+ if '"usage"' not in line:
91
+ continue
92
+ try:
93
+ r = json.loads(line)
94
+ except ValueError:
95
+ continue
96
+ if r.get("type") != "assistant":
97
+ continue
98
+ m = r.get("message") or {}
99
+ u = m.get("usage") or {}
100
+ cc = u.get("cache_creation") or {}
101
+ inp, out = u.get("input_tokens") or 0, u.get("output_tokens") or 0
102
+ cr, cw = u.get("cache_read_input_tokens") or 0, u.get("cache_creation_input_tokens") or 0
103
+ steps += 1
104
+ tokens += inp + out + cr + cw
105
+ ctx = inp + cr + cw
106
+ c5 = cc.get("ephemeral_5m_input_tokens") or 0
107
+ c1 = cc.get("ephemeral_1h_input_tokens") or 0
108
+ if not (c5 or c1):
109
+ c5 = cw
110
+ cost += pricing.estimate(m.get("model"), inp, out, cr, c5, c1)
111
+ return {"transcript": fp, "steps": steps, "tokens": tokens, "context": ctx,
112
+ "est_cost_usd": cost, "last_write_s": time.time() - os.path.getmtime(fp)}
113
+
114
+
115
+ def list_sessions(pricing):
116
+ procs = _ps()
117
+ mine = _ancestors(os.getpid(), procs) # the Claude session that launched this server
118
+ out = []
119
+ for fp in glob.glob(os.path.join(REG, "*.json")):
120
+ try:
121
+ with open(fp) as fh:
122
+ reg = json.load(fh)
123
+ except (OSError, ValueError):
124
+ continue
125
+ pid = reg.get("pid")
126
+ p = procs.get(pid)
127
+ alive = bool(p) and _is_claude(p) and (
128
+ not reg.get("procStart") or reg["procStart"] == p["lstart"])
129
+ if not alive:
130
+ continue
131
+ st = _transcript_stats(reg.get("sessionId"), pricing)
132
+ ctx = st.get("context") or 0
133
+ out.append({
134
+ "pid": pid, "session_id": reg.get("sessionId"), "name": reg.get("name"),
135
+ "cwd": reg.get("cwd"), "project": os.path.basename(reg.get("cwd") or ""),
136
+ "status": reg.get("status"), "kind": reg.get("kind"), "version": reg.get("version"),
137
+ "started_at": reg.get("startedAt"), "uptime": p["etime"],
138
+ "memory_mb": round(p["rss_kb"] / 1024), "hosts_dashboard": pid in mine,
139
+ "severity": "high" if ctx >= 300_000 else "medium" if ctx >= 150_000 else "ok",
140
+ **st,
141
+ })
142
+ out.sort(key=lambda x: (-(x.get("context") or 0)))
143
+ return out
144
+
145
+
146
+ def act(pid, action):
147
+ if action not in ACTIONS:
148
+ return {"ok": False, "error": f"unknown action {action}"}
149
+ procs = _ps()
150
+ reg_path = os.path.join(REG, f"{pid}.json")
151
+ try:
152
+ with open(reg_path) as fh:
153
+ reg = json.load(fh)
154
+ except (OSError, ValueError):
155
+ return {"ok": False, "error": "Not a registered Claude Code session."}
156
+ p = procs.get(pid)
157
+ if not p or not _is_claude(p):
158
+ return {"ok": False, "error": "Process is no longer running."}
159
+ if reg.get("procStart") and reg["procStart"] != p["lstart"]:
160
+ return {"ok": False, "error": "PID was reused by another process; refusing."}
161
+ sig, msg = ACTIONS[action]
162
+ try:
163
+ os.kill(pid, sig)
164
+ except ProcessLookupError:
165
+ return {"ok": False, "error": "Process already exited."}
166
+ except PermissionError:
167
+ return {"ok": False, "error": "Not permitted to signal this process."}
168
+ if action in ("close", "kill"):
169
+ for _ in range(20): # wait up to 2s to report the real outcome
170
+ time.sleep(0.1)
171
+ if pid not in _ps():
172
+ return {"ok": True, "exited": True, "message": msg,
173
+ "resume": f"claude --resume {reg.get('sessionId')}"}
174
+ return {"ok": True, "exited": False,
175
+ "message": msg + " It is still running — use Force kill if it doesn't exit.",
176
+ "resume": f"claude --resume {reg.get('sessionId')}"}
177
+ return {"ok": True, "message": msg}
178
+
179
+
180
+ # ------------------------------------------------------------ other agents ----
181
+ # Codex, Gemini CLI and Cursor keep no pid registry, so a session counts as running
182
+ # when its transcript was written recently. A CLI session is tied to a process by
183
+ # working directory; Cursor sessions live inside the IDE and are never signalled.
184
+ LIVE_WINDOW_S = 20 * 60
185
+ BUSY_WINDOW_S = 60
186
+ AGENT_BINS = {"codex": ("codex",), "gemini": ("gemini",)}
187
+ RESUME = {"codex": "codex resume {sid}", "gemini": "gemini --resume"}
188
+
189
+
190
+ def _is_agent(p, agent):
191
+ if agent == "claude":
192
+ return _is_claude(p)
193
+ words = [os.path.basename(w.strip('"').replace("\\", "/")).lower()
194
+ for w in (p["command"] or "").split()[:3]]
195
+ return any(w in AGENT_BINS.get(agent, ()) or w in tuple(b + ".exe" for b in AGENT_BINS.get(agent, ()))
196
+ for w in words)
197
+
198
+
199
+ def _cwd_of(pid):
200
+ if os.name == "nt":
201
+ return None
202
+ try:
203
+ out = subprocess.run(["lsof", "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
204
+ capture_output=True, text=True, timeout=5).stdout
205
+ except (OSError, subprocess.TimeoutExpired):
206
+ return None
207
+ return next((l[1:] for l in out.splitlines() if l.startswith("n")), None)
208
+
209
+
210
+ def _recent(pattern, now):
211
+ out = []
212
+ for fp in glob.glob(pattern, recursive=True):
213
+ try:
214
+ age = now - os.path.getmtime(fp)
215
+ except OSError:
216
+ continue
217
+ if age <= LIVE_WINDOW_S:
218
+ out.append((fp, age))
219
+ return out
220
+
221
+
222
+ def _codex_live(fp, pricing):
223
+ meta, model, steps, tokens, ctx, cost, prev = {}, "unknown", 0, 0, None, 0.0, None
224
+ with open(fp, errors="replace") as fh:
225
+ for line in fh:
226
+ try:
227
+ r = json.loads(line)
228
+ except ValueError:
229
+ continue
230
+ typ, p = r.get("type"), r.get("payload") or {}
231
+ if typ == "session_meta":
232
+ meta = p
233
+ elif typ == "turn_context":
234
+ model = p.get("model") or model
235
+ elif typ == "event_msg" and p.get("type") == "token_count" and p.get("info"):
236
+ tot = tuple(sorted(((p["info"] or {}).get("total_token_usage") or {}).items()))
237
+ if tot == prev:
238
+ continue
239
+ prev = tot
240
+ last = (p["info"] or {}).get("last_token_usage") or {}
241
+ cached = int(last.get("cached_input_tokens") or 0)
242
+ inp, out = int(last.get("input_tokens") or 0), int(last.get("output_tokens") or 0)
243
+ steps += 1
244
+ tokens += inp + out
245
+ ctx = inp
246
+ if pricing.is_known(model):
247
+ cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
248
+ return {"session_id": meta.get("id"), "cwd": meta.get("cwd"), "version": meta.get("cli_version"),
249
+ "model": model, "steps": steps, "tokens": tokens, "context": ctx, "est_cost_usd": cost}
250
+
251
+
252
+ def _gemini_live(fp, pricing):
253
+ with open(fp, errors="replace") as fh:
254
+ d = json.load(fh)
255
+ steps, tokens, ctx, cost, model = 0, 0, None, 0.0, None
256
+ for m in d.get("messages") or []:
257
+ if m.get("type") != "gemini":
258
+ continue
259
+ t = m.get("tokens") or {}
260
+ model = m.get("model") or model
261
+ cached, inp = int(t.get("cached") or 0), int(t.get("input") or 0)
262
+ out = int(t.get("output") or 0) + int(t.get("thoughts") or 0)
263
+ steps += 1
264
+ tokens += inp + out
265
+ ctx = inp
266
+ if pricing.is_known(model):
267
+ cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
268
+ return {"session_id": d.get("sessionId"), "cwd": None, "model": model, "steps": steps,
269
+ "tokens": tokens, "context": ctx, "est_cost_usd": cost}
270
+
271
+
272
+ def _cursor_live(fp, pricing):
273
+ steps = 0
274
+ with open(fp, errors="replace") as fh:
275
+ for line in fh:
276
+ if '"tool_use"' in line:
277
+ steps += line.count('"tool_use"')
278
+ slug = os.path.relpath(fp, os.path.join(HOME, ".cursor", "projects")).split(os.sep)[0]
279
+ guess = "/" + slug.replace("-", "/")
280
+ return {"session_id": os.path.splitext(os.path.basename(fp))[0],
281
+ "cwd": guess if os.path.isdir(guess) else None, "project_hint": slug.rsplit("-", 1)[-1],
282
+ "model": None, "steps": steps, "tokens": None, "context": None, "est_cost_usd": None}
283
+
284
+
285
+ LIVE_SOURCES = {
286
+ "codex": (os.path.join(HOME, ".codex", "sessions", "**", "*.jsonl"), _codex_live),
287
+ "gemini": (os.path.join(HOME, ".gemini", "tmp", "*", "chats", "*.json"), _gemini_live),
288
+ "cursor": (os.path.join(HOME, ".cursor", "projects", "*", "agent-transcripts", "*", "*.jsonl"), _cursor_live),
289
+ }
290
+
291
+
292
+ def list_agent_sessions(pricing, agents=None, procs=None):
293
+ procs = procs if procs is not None else _ps()
294
+ now = time.time()
295
+ cli = {a: [(pid, p) for pid, p in procs.items() if _is_agent(p, a)] for a in AGENT_BINS}
296
+ cwds = {}
297
+ out = []
298
+ for agent, (pattern, parse) in LIVE_SOURCES.items():
299
+ if agents and agent not in agents:
300
+ continue
301
+ for fp, age in _recent(pattern, now):
302
+ try:
303
+ st = parse(fp, pricing)
304
+ except (OSError, ValueError):
305
+ continue
306
+ match = None
307
+ for pid, p in cli.get(agent, []):
308
+ if pid not in cwds:
309
+ cwds[pid] = _cwd_of(pid)
310
+ if st.get("cwd") is None or cwds[pid] == st.get("cwd"):
311
+ match = (pid, p)
312
+ break
313
+ ctx = st.get("context") or 0
314
+ cwd = st.get("cwd")
315
+ out.append({
316
+ "agent": agent, "pid": match[0] if match else None,
317
+ "signalable": bool(match), "session_id": st.get("session_id"), "name": None,
318
+ "cwd": cwd, "project": os.path.basename(cwd or "") or st.get("project_hint") or agent,
319
+ "status": "busy" if age <= BUSY_WINDOW_S else "idle", "version": st.get("version"),
320
+ "model": st.get("model"), "uptime": match[1]["etime"] if match else "",
321
+ "memory_mb": round(match[1]["rss_kb"] / 1024) if match else None,
322
+ "hosts_dashboard": False, "transcript": fp, "last_write_s": age,
323
+ "steps": st.get("steps"), "tokens": st.get("tokens"), "context": st.get("context"),
324
+ "est_cost_usd": st.get("est_cost_usd"),
325
+ "resume": RESUME.get(agent, "").format(sid=st.get("session_id") or "") or None,
326
+ "severity": "high" if ctx >= 300_000 else "medium" if ctx >= 150_000 else "ok",
327
+ })
328
+ return out
329
+
330
+
331
+ def act_agent(pid, action, agent):
332
+ """Signal a Codex / Gemini CLI process — only one the live list currently shows."""
333
+ if action not in ACTIONS:
334
+ return {"ok": False, "error": f"unknown action {action}"}
335
+ if agent not in AGENT_BINS:
336
+ return {"ok": False, "error": "This agent's sessions can't be stopped from here."}
337
+ procs = _ps()
338
+ p = procs.get(pid)
339
+ if not p or not _is_agent(p, agent):
340
+ return {"ok": False, "error": "Process is no longer running."}
341
+ live = [s for s in list_agent_sessions(_NoPricing(), [agent], procs) if s["pid"] == pid]
342
+ if not live:
343
+ return {"ok": False, "error": "Not a live session the dashboard shows; refusing."}
344
+ sig, msg = ACTIONS[action]
345
+ try:
346
+ os.kill(pid, sig)
347
+ except ProcessLookupError:
348
+ return {"ok": False, "error": "Process already exited."}
349
+ except PermissionError:
350
+ return {"ok": False, "error": "Not permitted to signal this process."}
351
+ res = {"ok": True, "message": msg.replace("`claude --resume`", "its resume command"),
352
+ "resume": live[0]["resume"]}
353
+ if action in ("close", "kill"):
354
+ for _ in range(20):
355
+ time.sleep(0.1)
356
+ if pid not in _ps():
357
+ res["exited"] = True
358
+ return res
359
+ res["exited"] = False
360
+ return res
361
+
362
+
363
+ class _NoPricing:
364
+ def is_known(self, model):
365
+ return False
366
+
367
+
368
+ # ---------------------------------------------------------------- handover ----
369
+ HANDOFFS = os.path.join(HOME, ".claude", "finops-handoffs")
370
+ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
371
+
372
+
373
+ def _text_of(content):
374
+ if isinstance(content, str):
375
+ return content
376
+ return "\n".join(b.get("text", "") for b in content or []
377
+ if isinstance(b, dict) and b.get("type") == "text")
378
+
379
+
380
+ def _clip(s, n):
381
+ s = (s or "").strip()
382
+ return s if len(s) <= n else s[:n].rstrip() + " …"
383
+
384
+
385
+ def brief_from_transcript(fp):
386
+ """Everything a fresh session needs, read straight from the transcript —
387
+ no model call, so building a handover costs nothing."""
388
+ prompts, files, todos, last_reply = [], [], [], ""
389
+ with open(fp, errors="replace") as fh:
390
+ for line in fh:
391
+ try:
392
+ r = json.loads(line)
393
+ except ValueError:
394
+ continue
395
+ m = r.get("message") or {}
396
+ if r.get("type") == "user" and not r.get("isMeta") and not r.get("isSidechain"):
397
+ t = _text_of(m.get("content"))
398
+ if t and not t.lstrip().startswith(("<command-", "<local-command", "<system-reminder", "Caveat:")):
399
+ prompts.append(t)
400
+ elif r.get("type") == "assistant" and not r.get("isSidechain"):
401
+ for b in m.get("content") or []:
402
+ if not isinstance(b, dict):
403
+ continue
404
+ if b.get("type") == "text" and b.get("text", "").strip():
405
+ last_reply = b["text"]
406
+ elif b.get("type") == "tool_use":
407
+ inp = b.get("input") or {}
408
+ if b.get("name") in EDIT_TOOLS:
409
+ f = inp.get("file_path") or inp.get("notebook_path")
410
+ if f and f not in files:
411
+ files.append(f)
412
+ elif b.get("name") == "TodoWrite":
413
+ todos = inp.get("todos") or todos
414
+ return {"goal": prompts[0] if prompts else "", "recent": prompts[-6:] if len(prompts) > 1 else [],
415
+ "files": files, "todos": todos, "last_reply": last_reply, "turns": len(prompts)}
416
+
417
+
418
+ def _render_brief(reg, b, task=None, part=None):
419
+ L = [f"# Handover from session {reg.get('sessionId')}", "",
420
+ f"- Project: `{reg.get('cwd')}`",
421
+ f"- Previous session had {b['turns']} prompts; its full history is available with "
422
+ f"`claude --resume {reg.get('sessionId')}` if you truly need it — prefer this brief.", ""]
423
+ if task:
424
+ L += [f"## Your task{f' (part {part})' if part else ''}", "", task, "",
425
+ "Only do this task. Other parts of the work are handled in separate sessions.", ""]
426
+ L += ["## Original goal", "", _clip(b["goal"], 2500), ""]
427
+ if b["recent"]:
428
+ L += ["## Most recent requests (oldest first)", ""] + [f"- {_clip(p, 600)}" for p in b["recent"]] + [""]
429
+ open_todos = [t for t in b["todos"] if t.get("status") != "completed"]
430
+ if b["todos"]:
431
+ L += ["## Task list at handover", ""] + [
432
+ f"- [{'x' if t.get('status') == 'completed' else ' '}] {t.get('content')}" for t in b["todos"]] + [""]
433
+ if b["files"]:
434
+ L += ["## Files changed so far", ""] + [f"- `{f}`" for f in b["files"][-40:]] + [""]
435
+ if b["last_reply"]:
436
+ L += ["## Last status from the previous session", "", _clip(b["last_reply"], 3000), ""]
437
+ L += ["## How to start", "",
438
+ "Read the files above that matter for the task, verify the current state (it may have moved on "
439
+ "since this brief), then continue." + (" Open items from the task list come first." if open_todos else "")]
440
+ return "\n".join(L)
441
+
442
+
443
+ def _launch_terminal(cwd, prompt_path):
444
+ """Open a new Terminal window running a fresh `claude` seeded with the brief."""
445
+ q = lambda s: "'" + s.replace("'", "'\\''") + "'"
446
+ seed = f"Read the handover brief at {prompt_path} and continue from it."
447
+ cmd = f"cd {q(cwd)} && claude {q(seed)}"
448
+ if sys.platform == "darwin":
449
+ osa = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(cmd) + "\nend tell"
450
+ r = subprocess.run(["osascript", "-e", osa], capture_output=True, text=True)
451
+ return r.returncode == 0, (r.stderr or "").strip()
452
+ if os.name == "nt":
453
+ subprocess.Popen(["cmd", "/c", "start", "", "cmd", "/k", "claude", seed], cwd=cwd)
454
+ return True, ""
455
+ for term in (["x-terminal-emulator", "-e"], ["gnome-terminal", "--"], ["konsole", "-e"],
456
+ ["xfce4-terminal", "-x"], ["xterm", "-e"]):
457
+ if shutil.which(term[0]):
458
+ subprocess.Popen(term + ["bash", "-lc", cmd + "; exec bash"])
459
+ return True, ""
460
+ return False, "No terminal emulator found. Run this yourself: " + cmd
461
+
462
+
463
+ def handover(pid, opts):
464
+ """Start fresh session(s) from a brief of this one.
465
+
466
+ opts: tasks – list of sub-tasks; one new session each (empty = one continuation)
467
+ close – close the old session afterwards (SIGTERM, resumable)
468
+ launch – open Terminal windows (False = only write the briefs)
469
+ """
470
+ try:
471
+ with open(os.path.join(REG, f"{pid}.json")) as fh:
472
+ reg = json.load(fh)
473
+ except (OSError, ValueError):
474
+ return {"ok": False, "error": "Not a registered Claude Code session."}
475
+ files = glob.glob(os.path.join(PROJECTS, "*", f"{reg.get('sessionId')}.jsonl"))
476
+ if not files:
477
+ return {"ok": False, "error": "Transcript not found for this session."}
478
+ b = brief_from_transcript(files[0])
479
+ tasks = [t.strip() for t in (opts.get("tasks") or []) if t and t.strip()][:8]
480
+ stamp = time.strftime("%Y%m%d-%H%M%S")
481
+ os.makedirs(HANDOFFS, exist_ok=True)
482
+ out, jobs = [], tasks or [None]
483
+ for i, t in enumerate(jobs, 1):
484
+ path = os.path.join(HANDOFFS, f"{stamp}-{reg.get('sessionId', 'x')[:8]}-{i}.md")
485
+ with open(path, "w") as fh:
486
+ fh.write(_render_brief(reg, b, t, f"{i} of {len(jobs)}" if len(jobs) > 1 else None))
487
+ item = {"brief": path, "task": t, "launched": False}
488
+ if opts.get("launch", True):
489
+ item["launched"], err = _launch_terminal(reg.get("cwd") or HOME, path)
490
+ if err:
491
+ item["error"] = err
492
+ out.append(item)
493
+ res = {"ok": True, "sessions": out,
494
+ "message": f"{'Started' if opts.get('launch', True) else 'Wrote briefs for'} {len(out)} "
495
+ f"new session{'s' * (len(out) > 1)} from a handover brief."}
496
+ if opts.get("close") and all(x["launched"] for x in out):
497
+ res["closed"] = act(pid, "close")
498
+ res["message"] += " Old session closed (resumable)."
499
+ return res