Repository navigation
Expand file tree
/
Copy pathsession_watch.py
More file actions
1289 lines (1109 loc) · 52.5 KB
/
Copy pathsession_watch.py
File metadata and controls
1289 lines (1109 loc) · 52.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Watch every Claude Code session on this machine.
Three levels, and the distinction matters: a *process* is one `claude`; a
*conversation* is one `sessionId` and one transcript; a *project* is one cwd.
Measured on 2026-09-03, 17 live processes were 14 conversations in 10
projects — three processes shared one hammer conversation. Counting processes
is what made JARVIS miscount sessions out loud, so speech uses conversations.
Pure filesystem reading: no asyncio, no server imports, no LLM. Every parse is
failure-tolerant, because these files are written live by other processes and
will be read mid-write.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
# Both roots must be read: the CLI's default and the one Orcha sets via
# CLAUDE_CONFIG_DIR. On the dev machine `~/.claude/sessions` was empty and
# every session lived under `~/.claude-orcha` — a plain-Terminal user is the
# mirror image, so neither root may be dropped.
DEFAULT_ROOTS = ("~/.claude", "~/.claude-orcha")
def config_roots() -> list[Path]:
"""Every directory that may hold a `sessions/` roster, in priority order."""
roots = [Path(r).expanduser() for r in DEFAULT_ROOTS]
extra = os.getenv("JARVIS_CLAUDE_CONFIG_DIRS", "")
for raw in extra.split(os.pathsep):
raw = raw.strip()
if raw:
p = Path(raw).expanduser()
if p not in roots:
roots.append(p)
return roots
def pid_alive(pid) -> bool:
"""True if the process exists. Signal 0 checks without touching it.
`pid` must be a positive integer: 0 means "this process's group" and a
negative pid means "that group" to `os.kill`, neither of which is a real
process, so both are rejected before the syscall.
"""
try:
pid = int(pid)
if pid <= 0:
return False
os.kill(pid, 0)
except (OSError, TypeError, ValueError):
return False
return True
def encode_cwd(cwd: str) -> str:
"""The CLI's transcript directory name: EVERY non-alphanumeric becomes `-`.
Not just `/`. A worktree path contains dots, and the `/`-only shortcut
silently fails to find its transcript.
"""
return re.sub(r"[^a-zA-Z0-9]", "-", cwd)
def _ms(value) -> float | None:
"""Roster timestamps are epoch milliseconds; we work in seconds."""
try:
return float(value) / 1000.0
except (TypeError, ValueError):
return None
@dataclass(frozen=True)
class RosterEntry:
"""One `sessions/<pid>.json` — one live `claude` process."""
pid: int
session_id: str
cwd: str
name: str
root: Path
kind: str = "interactive"
entrypoint: str = "cli"
status: str | None = None
waiting_for: str | None = None
started_at: float | None = None
status_updated_at: float | None = None
socket_path: str | None = None
version: str = ""
@property
def steerable(self) -> bool:
"""A process can only be steered if it bound an inbox socket.
Measured: 4 of 17 live entries had none. `ListAgents` cannot see those
at all, which is why this watcher exists.
"""
return bool(self.socket_path) and Path(self.socket_path).exists()
def _parse_entry(path: Path, root: Path) -> RosterEntry | None:
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return None # unreadable, empty, or caught mid-write
if not isinstance(data, dict):
return None
try:
pid = int(data["pid"])
session_id = str(data["sessionId"])
cwd = str(data["cwd"])
except (KeyError, TypeError, ValueError):
return None # without these three it is not a session
return RosterEntry(
pid=pid,
session_id=session_id,
cwd=cwd,
name=str(data.get("name") or Path(cwd).name or session_id[:8]),
root=root,
kind=str(data.get("kind") or "interactive"),
entrypoint=str(data.get("entrypoint") or "cli"),
status=data.get("status") if isinstance(data.get("status"), str) else None,
waiting_for=(data.get("waitingFor")
if isinstance(data.get("waitingFor"), str) and data.get("waitingFor")
else None),
started_at=_ms(data.get("startedAt")),
status_updated_at=_ms(data.get("statusUpdatedAt")),
socket_path=(str(data["messagingSocketPath"])
if isinstance(data.get("messagingSocketPath"), str) else None),
version=str(data.get("version") or ""),
)
def read_roster(roots: list[Path] | None = None) -> list[RosterEntry]:
"""Every well-formed roster entry across every root. Never raises."""
entries: list[RosterEntry] = []
seen: set[tuple[int, str]] = set()
for root in (roots if roots is not None else config_roots()):
d = Path(root) / "sessions"
try:
files = sorted(d.glob("*.json"))
except OSError:
continue
for f in files:
entry = _parse_entry(f, Path(root))
if entry is None:
continue
key = (entry.pid, entry.session_id)
if key in seen: # the same pid registered under two roots
continue
seen.add(key)
entries.append(entry)
return entries
# A 64 KB tail was sufficient for every one of the 14 live transcripts on
# 2026-09-03, including a 95 MB one, because `ai-title` and `last-prompt` are
# rewritten on every turn and therefore always sit near the end.
TAIL_BYTES = 64 * 1024
MAX_RECENT_TOOLS = 5
MAX_TEXT = 600
@dataclass
class Recap:
"""What a session is doing, read from the end of its transcript."""
exists: bool = False
title: str | None = None # the CLI's own `aiTitle`
last_prompt: str | None = None # the CLI's own `lastPrompt`
last_text: str | None = None # what the session last said
recent_tools: list[str] = field(default_factory=list)
def summary(self) -> str | None:
"""One line describing the session: its title, else its last prompt."""
return self.title or self.last_prompt
def transcript_path(root: Path, cwd: str, session_id: str) -> Path:
return Path(root) / "projects" / encode_cwd(cwd) / f"{session_id}.jsonl"
def tail_objects(path: Path, nbytes: int = TAIL_BYTES) -> list[dict]:
"""Parse the JSON objects in the last `nbytes` of a JSONL file.
Seeking into a multi-megabyte file always lands mid-line, so the first
(partial) line is discarded. Unparseable lines are skipped: this file is
appended to by another process while we read it.
"""
try:
with open(path, "rb") as fh:
fh.seek(0, os.SEEK_END)
size = fh.tell()
if size > nbytes:
fh.seek(size - nbytes)
fh.readline() # discard the partial line
else:
fh.seek(0)
raw = fh.read()
except OSError:
return []
out: list[dict] = []
for line in raw.decode("utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
if isinstance(obj, dict):
out.append(obj)
return out
def _clip(text, limit: int = MAX_TEXT) -> str | None:
"""One line of clipped text, or None — for a value of ANY shape.
Every caller reads a field out of somebody else's transcript, so the type
check belongs here rather than at each call site: the call sites had it
twice out of three times, and the third raised AttributeError on a
non-string `text` block, which escaped to `SessionWatcher._loop`, was
logged as a warning and swallowed, and left `self.snapshot` FROZEN at the
last good poll for as long as that line stayed inside the 64 KB tail.
`/api/sessions` went on answering 200 the whole time.
"""
if not isinstance(text, str) or not text:
return None
text = " ".join(text.split())
return text if len(text) <= limit else text[: limit - 1] + "…"
def recap_from(objs: list[dict]) -> Recap:
"""Reduce transcript lines to a recap. Unknown line types are ignored —
19 types were observed on the live machine and only these five are used."""
r = Recap(exists=True)
tools: list[str] = []
for o in objs:
kind = o.get("type")
if kind == "ai-title":
r.title = _clip(o.get("aiTitle"), 200) or r.title
elif kind == "last-prompt":
r.last_prompt = _clip(o.get("lastPrompt")) or r.last_prompt
elif kind == "assistant" and not o.get("isSidechain"):
message = o.get("message")
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
r.last_text = _clip(block.get("text")) or r.last_text
elif block.get("type") == "tool_use" and block.get("name"):
# `_clip` like its three siblings above, and for the same
# reason: this is a string out of somebody else's
# transcript, of any length and any shape, and it reaches
# a header line in `tool_session_detail`.
tools.append(_clip(block["name"]) or "")
r.recent_tools = tools[-MAX_RECENT_TOOLS:]
return r
def _roots_of(group: list, primary) -> list[Path]:
"""Every root this conversation registered in, the primary's first.
Order matters: where two roots both hold a transcript (they are
hardlinked on the live machine, so this is the normal case) the
primary's is the one read.
"""
out = [primary.root]
for e in group:
if e.root not in out:
out.append(e.root)
return out
def _first_recap(roots: list[Path], cwd: str, session_id: str) -> Recap:
"""The first root that actually has this conversation's transcript.
`Recap(exists=False)` — no transcript anywhere — is what makes a session
`fresh`, so looking under one root and stopping reported a busy
conversation as "not started".
"""
for root in roots:
recap = read_recap(root, cwd, session_id)
if recap.exists:
return recap
return Recap(exists=False)
def read_recap(root: Path, cwd: str, session_id: str,
nbytes: int = TAIL_BYTES) -> Recap:
"""Recap a session, or `Recap(exists=False)` if it has no transcript.
Absence is meaningful: a session nobody has prompted writes no transcript
at all, which is how `fresh` is detected without parsing anything.
"""
path = transcript_path(root, cwd, session_id)
if not path.exists():
return Recap(exists=False)
return recap_from(tail_objects(path, nbytes))
# Derived conversation states.
FRESH = "fresh" # alive but never prompted (no transcript) — never announced
NEEDS_YOU = "needs_you"
WORKING = "working"
SHELL = "shell"
IDLE = "idle"
GONE = "gone"
UNKNOWN = "unknown" # alive, but the roster carries no status for it
# Keywords matched against a lowercased `waitingFor` reason to flag one that
# a peer message cannot clear. The reason set itself is OPEN — "input needed"
# turned up after only "permission prompt" and "dialog open" had been seen,
# and more will appear. "input needed" is deliberately NOT in this list: a
# peer message over the inbox socket is exactly what a session waiting on
# input wants, so it stays answerable rather than getting waved off as
# needing a human hand. An unrecognised reason is likewise treated as
# answerable by default — JARVIS attempts it and reports the real outcome,
# rather than refusing something he could have done because the string
# didn't happen to be on this list yet.
HUMAN_HAND_REASONS = ("permission", "dialog", "trust", "login", "auth")
_QUESTION_TOOLS = ("AskUserQuestion", "ExitPlanMode")
_URL_RE = re.compile(r"\S+://\S+|\bwww\.\S+")
def _looks_like_a_question(text: str | None) -> bool:
"""Did the session stop to ask something?
A bare `"?" in tail` is too eager: a URL query string counts. Measured on
the live machine — a session that had hit its spend limit ended with
`…/settings/usage?from=cc_cli_limit_message`, and was announced as needing
the user. Needs-you announcements interrupt at URGENT priority, so a false
positive here nags the user about a session that wants nothing.
URLs are stripped first, then a question mark only counts where a question
actually ends: at the close of the message or of one of its last lines.
"""
if not text:
return False
cleaned = _URL_RE.sub(" ", text).rstrip()
if not cleaned:
return False
if cleaned.endswith("?"):
return True
# A question can be followed by options ("...or SQLite?\n1. Postgres").
return any(line.rstrip().endswith("?") for line in cleaned.splitlines()[-6:])
@dataclass
class SessionState:
"""One conversation: what it is, what it is doing, and how to reach it."""
session_id: str
cwd: str
project: str
state: str
pids: list[int] = field(default_factory=list)
primary_pid: int | None = None
roster_name: str = ""
voice_name: str = ""
needs: str | None = None
title: str | None = None
last_prompt: str | None = None
last_text: str | None = None
recent_tools: list[str] = field(default_factory=list)
# When the SESSION STARTED. Used for age ordering (`_name_by_age`'s
# "the newer hammer" / "the older hammer"). Do not use this for spoken
# elapsed time — see `since` below.
started: float | None = None
# When the CURRENT STATE began. Used for spoken elapsed time ("hammer has
# been waiting about an hour"). Measured on the live roster: the largest
# gap between `startedAt` and `statusUpdatedAt` was 102 HOURS (VoyageStudios,
# pid 37497), with another material 4.2-hour gap on a hammer session — so
# `started` and `since` are NOT interchangeable and must stay two fields.
# Do not "simplify" these back into one.
since: float | None = None
origin: str = "terminal"
steerable: bool = False
socket_path: str | None = None
# Is this the conversation the user is actually sitting at, in this
# project? See `_mark_primary` for the rule and why it can answer "no"
# about every conversation in a project.
primary: bool = False
primary_reason: str = ""
# Subagents this conversation dispatched, counted off its own
# `<sessionId>/subagents/` folder. `agents_active` means "written to
# within AGENT_ACTIVE_WITHIN_SEC", never "a process is alive" — there is
# no process here to check.
agents_seen: int = 0
agents_active: int = 0
# `agents_seen` hit MAX_AGENT_FILES: it is a floor, not a total.
agents_capped: bool = False
@property
def announceable(self) -> bool:
"""`fresh` sessions are never spoken about unprompted: nobody has
started them, so 'chitauri needs you' would be nonsense."""
return self.state not in (FRESH,)
@property
def needs_a_human_hand(self) -> bool:
"""True when the thing it waits on cannot be answered over the socket."""
reason = (self.needs or "").lower()
return any(word in reason for word in HUMAN_HAND_REASONS)
def summary(self) -> str | None:
return self.title or self.last_prompt
# ── which conversation is the main one ──────────────────────────────────────
#
# There was no notion of a PRIMARY conversation before this. The rule, in
# full, because a rule nobody can state is a rule nobody can check:
#
# Primary is decided PER PROJECT. A conversation is ELIGIBLE if it is
# alive, has been prompted at least once (a `fresh` session has no
# transcript and is nobody's main session), and was started interactively
# — `entrypoint: cli`, i.e. origin "terminal". A background/SDK session is
# never the main one: nobody is typing into it.
#
# Among the eligible, the most recently active wins, measured by `since`
# (the roster's `statusUpdatedAt`).
#
# AND: if the runner-up is within PRIMARY_MARGIN_SEC of the leader, NEITHER
# is primary. Two conversations touched a minute apart are equally live and
# the signal cannot separate them; claiming one anyway would be a guess
# wearing a fact's clothes.
#
# Signals deliberately NOT used: transcript size (a long conversation is an
# old one, not an active one) and whether JARVIS spawned it (his own runs are
# filtered out of the roster upstream, in server._snapshot_or_empty, so they
# never reach this rule at all).
PRIMARY_MARGIN_SEC = 120.0
PRIMARY_ONLY = "the only live conversation here"
PRIMARY_RECENT = "most recently active"
PRIMARY_TIED = "equally live as another here"
NOT_PRIMARY_BACKGROUND = "a background conversation"
NOT_PRIMARY_FRESH = "never prompted"
NOT_PRIMARY_GONE = "finished"
def _mark_primary(sessions: list[SessionState]) -> None:
"""Set `primary` / `primary_reason` on every conversation."""
by_project: dict[str, list[SessionState]] = {}
for s in sessions:
if s.state == GONE:
s.primary, s.primary_reason = False, NOT_PRIMARY_GONE
elif s.state == FRESH:
s.primary, s.primary_reason = False, NOT_PRIMARY_FRESH
elif s.origin != "terminal":
s.primary, s.primary_reason = False, NOT_PRIMARY_BACKGROUND
else:
s.primary, s.primary_reason = False, NOT_PRIMARY_BACKGROUND
by_project.setdefault(s.project, []).append(s)
for group in by_project.values():
if len(group) == 1:
group[0].primary, group[0].primary_reason = True, PRIMARY_ONLY
continue
ranked = sorted(
group,
key=lambda s: s.since if s.since is not None else float("-inf"),
reverse=True)
lead, runner = ranked[0], ranked[1]
lead_at = lead.since
runner_at = runner.since
separated = (lead_at is not None and
(runner_at is None or lead_at - runner_at > PRIMARY_MARGIN_SEC))
if not separated:
# Inside the margin nothing is claimed. Every member of the tie
# says so; the ones further back are simply background.
for s in ranked:
at = s.since
tied = at is not None and lead_at is not None and \
lead_at - at <= PRIMARY_MARGIN_SEC
s.primary_reason = PRIMARY_TIED if tied else NOT_PRIMARY_BACKGROUND
continue
lead.primary, lead.primary_reason = True, PRIMARY_RECENT
for s in ranked[1:]:
s.primary, s.primary_reason = False, NOT_PRIMARY_BACKGROUND
# ── subagents ───────────────────────────────────────────────────────────────
# A subagent transcript written this recently is taken to be working. This is
# a FILE age, stated as one — there is no process to check here, and calling
# it "running" would be a claim the data does not support.
AGENT_ACTIVE_WITHIN_SEC = 90.0
# Cap the stat() calls one conversation can cost a 1 Hz poll. 209 subagent
# transcripts were measured under a single conversation.
MAX_AGENT_FILES = 300
def count_agents(roots, cwd: str, session_id: str,
now: float) -> tuple[int, int, bool]:
"""(subagent transcripts seen, how many were written recently, capped?).
`roots` is one config root or several. Several are deduped BY FILE NAME:
the two roots are hardlinked on the live machine, so `agent-x.jsonl`
under each is one agent reached by two names, and unioning the listings
naively doubles every count on the page.
The CLI puts one file per dispatched subagent in
`projects/<encoded cwd>/<sessionId>/subagents/`. Measured 2026-09-03:
1,573 such files against 947 conversations.
THE CAP COUNTS TRANSCRIPTS, NOT DIRECTORY ENTRIES. Every `agent-x.jsonl`
has an `agent-x.json` sidecar beside it, so one live folder held 418
entries for 209 agents — slicing the raw listing first reported 150,
which looked exactly like a real number. Filter, then cap.
BOTH COUNTS ARE FLOORS ONCE THE CAP IS HIT. `seen` obviously; `active`
just as much, because the slice is taken by FILE NAME and file names are
uncorrelated with recency — the 300 examined may hold none of the busy
ones. The third value says so, and both pills must render it.
Never raises: this folder belongs to another process, and files appear
and vanish underneath it.
"""
by_name: dict[str, Path] = {}
for root in ([roots] if isinstance(roots, (str, Path)) else roots):
d = (Path(root) / "projects" / encode_cwd(cwd) / session_id
/ "subagents")
try:
entries = list(d.iterdir())
except OSError:
continue
for f in entries:
if f.name.endswith(".jsonl"):
by_name.setdefault(f.name, f)
transcripts = [by_name[name] for name in sorted(by_name)]
capped = len(transcripts) > MAX_AGENT_FILES
seen = active = 0
for f in transcripts[:MAX_AGENT_FILES]:
try:
st = f.stat()
except OSError:
continue
if not os.path.isfile(f):
continue
seen += 1
if now - st.st_mtime <= AGENT_ACTIVE_WITHIN_SEC:
active += 1
return seen, active, capped
def project_name(cwd: str) -> str:
"""The project name for a cwd, tolerant of Claude Code's own worktree layout.
Claude Code creates worktrees under `<repo>/.claude/worktrees/<branch>`, so
a plain `Path(cwd).name` on such a path yields the branch/worktree name
(e.g. "runs-dashboard") instead of the repo the user actually means (e.g.
"jarvis"). When the path contains a `.claude/worktrees` segment, the
project is the directory name that PRECEDES `.claude`.
This covers Claude Code's own worktree convention ONLY. A worktree created
elsewhere (via plain `git worktree add`, outside `.claude/worktrees`) is
still named after its own directory: detecting that properly would need a
`git` subprocess call, and this runs on a 1-second poll loop, so it is
deliberately not attempted.
Two worktrees of the same repo therefore share a project name here, and
are told apart afterward by the existing voice-naming rules (folder, then
age) — that is correct and desirable, not a bug.
"""
parts = Path(cwd).parts
if ".claude" in parts:
i = parts.index(".claude")
if i > 0 and parts[i - 1] and i + 1 < len(parts) and parts[i + 1] == "worktrees":
return parts[i - 1]
return Path(cwd).name or cwd
def worktree_branch(cwd: str) -> str:
"""The worktree name in `<repo>/.claude/worktrees/<branch>`, or "".
The other half of `project_name`: that function answers "which project
is this", this one answers "which copy of it". Two worktrees of one repo
share a project name on purpose, so anything that shows both at once
needs this to tell them apart.
"""
parts = Path(cwd).parts
if ".claude" not in parts:
return ""
i = parts.index(".claude")
if i > 0 and i + 2 < len(parts) and parts[i + 1] == "worktrees":
return parts[i + 2]
return ""
_ORIGIN_BY_ENTRYPOINT = {
"cli": "terminal",
"sdk-cli": "background",
"claude-desktop": "desktop",
"desktop": "desktop",
}
def _origin(entry: RosterEntry) -> str:
return _ORIGIN_BY_ENTRYPOINT.get(entry.entrypoint, "terminal")
def brain_cwd() -> str:
"""Where JARVIS's own brain process runs: `data_paths.brain_home()`
(`<JARVIS_DATA_DIR>/jarvis`), computed here WITHOUT importing
`data_paths` and without its `mkdir` side effect.
This module stays pure filesystem *reading* — it is polled once a second
— so it must not create directories as a side effect of building a
snapshot, which `data_paths.data_dir()` does. The formula is kept in
exact lockstep with `data_paths.brain_home()` by
`test_brain_cwd_matches_data_paths_brain_home` in the test suite.
"""
raw = os.getenv("JARVIS_DATA_DIR")
base = Path(raw).expanduser() if raw else (Path(__file__).parent / "data")
return str(base / "jarvis")
# The private spelling this module has always used. `usage_scan` needs the
# same answer — the brain's transcript is not the user's work either — and
# reaching into another module's underscore would be worse than one alias.
_brain_cwd = brain_cwd
def _is_own_brain(entry: RosterEntry) -> bool:
"""True for JARVIS's own brain process, never a user conversation.
Measured live: the brain registers with `entrypoint="sdk-cli"` and a cwd
exactly equal to the brain home directory. The cwd match is checked
FIRST and is the deciding signal — a roster `name` of "jarvis" is NOT
used, because a user can legitimately have a project called "jarvis"
(this user does, at .../Projects/jarvis) and a name-based check would
wrongly exclude that real conversation too.
"""
if entry.entrypoint != "sdk-cli":
return False
try:
return _same_dir(entry.cwd, brain_cwd())
except (TypeError, ValueError):
return False
def _same_dir(a: str, b: str) -> bool:
"""Two spellings of one directory.
`realpath`, not a textual compare: `JARVIS_DATA_DIR` pointing at a
symlink — the ordinary shape when data lives on another volume — spells
the brain's home one way here and another way in the roster entry the
process wrote, and the brain then appeared as one of the user's own
conversations. `realpath` does not require the path to exist.
"""
if not a or not b:
return False
return os.path.realpath(a) == os.path.realpath(b)
def _pick_primary(entries: list[RosterEntry]) -> RosterEntry:
"""Which process do we steer? Alive first, then one with a live socket,
then the most recently active."""
return sorted(
entries,
key=lambda e: (pid_alive(e.pid), e.steerable, e.status_updated_at or 0.0),
reverse=True,
)[0]
def _derive_state(entries: list[RosterEntry], recap: Recap) -> tuple[str, str | None]:
"""The conversation's state and the reason it needs you, if it does."""
if not any(pid_alive(e.pid) for e in entries):
return GONE, None
if not recap.exists:
# Nobody has ever prompted it. True even when it sits at a startup
# dialog — measured on chitauri-67, which was `waiting`/`dialog open`.
return FRESH, None
live = [e for e in entries if pid_alive(e.pid)]
waiting = next((e for e in live if e.waiting_for), None)
if waiting is not None:
return NEEDS_YOU, waiting.waiting_for
if any(e.status == "waiting" for e in live):
return NEEDS_YOU, None
if any(e.status == "busy" for e in live):
return WORKING, None
if any(e.status == "shell" for e in live):
return SHELL, None
if all(e.status is None for e in live):
return UNKNOWN, None
# Idle, but it may have stopped to ask something.
if any(t in _QUESTION_TOOLS for t in recap.recent_tools) or \
_looks_like_a_question(recap.last_text):
return NEEDS_YOU, None
return IDLE, None
def _assign_voice_names(sessions: list[SessionState]) -> None:
"""Give every conversation a name a person can say and hear.
Collisions measured on the live machine: `hammer` had two conversations in
ONE directory (so the folder cannot disambiguate) and `chitauri` had three
across TWO directories (so the folder can). Roster suffixes like `-4b` are
never used: they are neither sayable nor hearable.
"""
by_project: dict[str, list[SessionState]] = {}
for s in sessions:
by_project.setdefault(s.project, []).append(s)
for project, group in by_project.items():
if len(group) == 1:
group[0].voice_name = project
continue
by_parent: dict[str, list[SessionState]] = {}
for s in group:
by_parent.setdefault(Path(s.cwd).parent.name, []).append(s)
if len(by_parent) > 1:
# The parent folder tells them apart — "chitauri in Desktop" vs
# "chitauri in Projects". A folder may still hold several, so those
# get the same fallback chain applied on top.
for parent, sub in by_parent.items():
base = f"{project} in {parent}"
if len(sub) == 1:
sub[0].voice_name = base
else:
_name_group(sub, base)
else:
_name_group(group, project)
def _name_group(group: list[SessionState], base: str) -> None:
"""Name several conversations that share a `base` (project, or "project
in parent"). Tries, in order, the strongest distinguisher that actually
distinguishes them: what they are ABOUT, then what they are DOING, and
only as a last resort how OLD they are. Age was the original — and
weakest — rule; a user was told "the newer one" / "the older one" with no
hint of which conversation was which. Each rule applies to the WHOLE
group at once: a group is never named half by topic and half by state,
because that would make one half's names orphan the other half's
convention when the user answers back.
"""
if _name_by_topic(group, base):
return
if _name_by_state(group, base):
return
_name_by_age(group, base)
# Generic words that carry no topic of their own, stripped from a title
# before a short phrase is pulled from what's left.
_TITLE_STOPWORDS = frozenset({
"a", "an", "the", "and", "or", "to", "for", "of", "in", "on", "with",
"at", "by", "from", "is", "are", "this", "that", "it", "its", "your",
"my", "our",
})
def _topic_words(project: str) -> set[str]:
return {w for w in re.split(r"[^a-z0-9]+", project.lower()) if w}
def _topic_phrase(title: str, project: str) -> str | None:
"""A short (<=2 word), sayable phrase distilled from a conversation's
title: generic stopwords and the project's own name are stripped out,
and the last couple of remaining words are kept — the part of a title
that tends to carry what's actually distinct about it ("...architecture
and handoff" -> "architecture handoff"). None if nothing is left to
distinguish it by.
"""
tokens = [w for w in re.split(r"[^a-z0-9]+", title.lower()) if w]
proj_words = _topic_words(project)
kept = [w for w in tokens if w not in _TITLE_STOPWORDS and w not in proj_words]
if not kept:
return None
return " ".join(kept[-2:])
def _name_by_topic(group: list[SessionState], base: str) -> bool:
"""Name every conversation in the group by what it's ABOUT.
Succeeds only when EVERY member has a non-empty title AND the phrases
pulled from those titles are pairwise distinct — a title missing on even
one member, or two titles boiling down to the same phrase, means topic
naming cannot tell the whole group apart, so nothing is assigned and the
caller falls through to the next rule for the group as a whole.
"""
phrases: dict[str, str] = {}
for s in group:
title = (s.title or "").strip()
if not title:
return False
phrase = _topic_phrase(title, s.project)
if not phrase:
return False
phrases[s.session_id] = phrase
if len(set(phrases.values())) != len(phrases):
return False
for s in group:
s.voice_name = f"{base}, the {phrases[s.session_id]} one"
return True
# Short, sayable phrases for what a conversation is doing right now, used
# when topic naming can't tell a group apart (title missing or identical)
# but their states differ.
_STATE_PHRASES = {
NEEDS_YOU: "that needs you",
WORKING: "that's working",
IDLE: "that's idle",
SHELL: "in a shell",
GONE: "that's finished",
FRESH: "that hasn't started",
UNKNOWN: "in an unclear state",
}
def _name_by_state(group: list[SessionState], base: str) -> bool:
"""Name every conversation in the group by what it is DOING.
Succeeds only when every member's state is pairwise distinct — two
conversations both sitting idle are not told apart by state either, so
the caller falls through to age for the group as a whole.
"""
states = [s.state for s in group]
if len(set(states)) != len(states):
return False
for s in group:
phrase = _STATE_PHRASES.get(s.state, "in an unclear state")
s.voice_name = f"the {base} {phrase}"
return True
def _name_by_age(group: list[SessionState], base: str) -> None:
"""Two conversations in one folder: 'the newer hammer' / 'the older hammer'.
Beyond two, fall back to ordinals, which are still sayable. The last
resort of the naming chain — it carries no information about what a
conversation IS, only how old it is."""
ordered = sorted(group, key=lambda s: s.started or 0.0, reverse=True)
if len(ordered) == 2:
ordered[0].voice_name = f"the newer {base}"
ordered[1].voice_name = f"the older {base}"
return
words = ("newest", "second", "third", "fourth", "fifth")
for i, s in enumerate(ordered):
s.voice_name = (f"the {words[i]} {base}" if i < len(words)
else f"{base} number {i + 1}")
# Words a person says around a name that carry no identity of their own.
# "the newer one" distinguishes; "the", "one", "session" do not.
_FILLER = frozenset({
"the", "a", "an", "one", "ones", "session", "sessions", "conversation",
"conversations", "project", "in", "on", "at", "my", "please", "that",
"this", "it", "s", "lets", "let", "go", "with", "use", "about",
})
def _name_words(s: "SessionState") -> set[str]:
"""Every word that could identify this conversation out loud."""
import re as _re
text = f"{s.voice_name} {s.project}".lower()
return {w for w in _re.split(r"[^a-z0-9-]+", text) if w}
def _prefer_real(matches: list[SessionState]) -> list[SessionState]:
"""Among several name/project matches, a `fresh` conversation (never
prompted, no transcript) is noise, not a real candidate — offering it
alongside a real conversation turns "ask chitauri where we left off"
into a clarifying question about a session with nothing to report.
Drop the fresh ones IF at least one match is real. If EVERY match is
fresh, keep them all: the user may genuinely mean a fresh session, and
silently returning nothing would be wrong. And when several matches are
real, this changes nothing — ambiguity among real conversations must
still return all of them so the caller asks; that is a safety property,
not a rough edge to smooth over.
"""
real = [s for s in matches if s.state != FRESH]
return real if real else matches
@dataclass
class Snapshot:
"""Every conversation on this machine at one instant."""
sessions: list[SessionState] = field(default_factory=list)
taken_at: float = 0.0
def by_id(self, session_id: str) -> SessionState | None:
return next((s for s in self.sessions if s.session_id == session_id), None)
def excluding(self, session_ids) -> "Snapshot":
"""This snapshot without those conversations, RE-DERIVED.
For JARVIS's own `claude -p` runs, which register in the roster like
anything else (measured 2026-09-04: `entrypoint: "sdk-cli"`,
`kind: "interactive"`). Dropping the rows is not enough on its own,
because the two things computed ACROSS a project — the voice name and
the "main" badge — were computed with the runs still in. A user with
one conversation in a project a run had also touched was told about
"the newer" and "the older" one, and then only one of them survived
the filter.
The same snapshot object comes back when nothing is excluded, so the
common case costs nothing.
"""
ids = set(session_ids)
if not ids:
return self
kept = [s for s in self.sessions if s.session_id not in ids]
if len(kept) == len(self.sessions):
return self
_assign_voice_names(kept)
_mark_primary(kept)
kept.sort(key=lambda s: (s.project, s.voice_name))
return Snapshot(sessions=kept, taken_at=self.taken_at)
def by_project(self) -> dict[str, list[SessionState]]:
out: dict[str, list[SessionState]] = {}
for s in self.sessions:
out.setdefault(s.project, []).append(s)
return out
def needing_you(self) -> list[SessionState]:
"""Waiting conversations, most-recently-waiting first.
Ordered by `since` (when the CURRENT STATE began) — NOT `started`
(when the conversation began). Those two fields differ by up to 102
hours on the live roster; sorting by `started` here would surface a
conversation that has sat waiting for days ahead of one that started
waiting a minute ago.
"""
return sorted(
(s for s in self.sessions if s.state == NEEDS_YOU),
key=lambda s: s.since if s.since is not None else float("-inf"),
reverse=True)
def resolve(self, reference: str, last_mentioned: str | None = None
) -> list[SessionState]:
"""Map what the user said to conversations.
Returns EVERY candidate when ambiguous. The caller must ask; picking
`.first` steers the wrong session and cannot be undone.
"""
ref = " ".join((reference or "").lower().split())
if not ref:
return []
if ref in ("that one", "that", "it", "the same one", "that session"):
s = self.by_id(last_mentioned) if last_mentioned else None
return [s] if s else []
exact = [s for s in self.sessions if s.voice_name.lower() == ref]
if exact:
return exact
sid = [s for s in self.sessions if s.session_id == reference]
if sid:
return sid
roster = [s for s in self.sessions if s.roster_name.lower() == ref]
if roster:
return roster
# Exact PROJECT match before any substring fallback. Verified live:
# with two `hammer` conversations plus a separate `hammer-private`
# project, substring matching alone made "hammer" a hit inside
# "hammer-private" too, offering an unrelated sibling project. An
# exact project-name match (case-insensitive) is unambiguous and
# must win outright, without also pulling in substring siblings.
exact_project = [s for s in self.sessions if s.project.lower() == ref]
if exact_project:
return _prefer_real(exact_project)
# A qualified answer to our OWN disambiguation question must resolve.
#
# Found live: asked "which one?", the user said "the newer one", and
# the substring rule below matched BOTH candidates again — the project
# name is inside every one of its own voice names — so the question
# looped forever and JARVIS said he could not pick. The ask-which-one
# flow was a dead end.
#
# So: if every distinguishing word the user said appears in a voice
# name, that name is a candidate, and the narrowest set wins. "newer
# webapp-fresh" keeps only "the newer webapp-fresh"; a bare
# "webapp-fresh" still matches both and still asks.
ref_words = {w for w in re.split(r"[^a-z0-9-]+", ref) if w and w not in _FILLER}
if ref_words:
token_hits = [s for s in self.sessions
if ref_words <= _name_words(s)]
if token_hits:
narrowed = _prefer_real(token_hits)
if len(narrowed) < len(self.sessions):
return narrowed
# Substring, both directions: "the chitauri one" contains "chitauri",
# and "hammer" is contained in "the newer hammer".
loose = [s for s in self.sessions
if s.voice_name.lower() in ref or s.project.lower() in ref