workmap 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.
workmap/model.py ADDED
@@ -0,0 +1,719 @@
1
+ """Data shapes, and what a process is.
2
+
3
+ Nothing here runs a program, signals one, or touches a disk, which is the
4
+ property that matters: the rules that decide what a process *is* are the rules
5
+ that decide what gets killed, and they are worth being able to test without a
6
+ machine underneath them. Every function here is a total function of its
7
+ arguments, with no exception. There used to be one, project_from_path(), which
8
+ resolved paths and read the configured roots: deciding which project a
9
+ directory belongs to is a question about a disk, so it has gone to config.py
10
+ where the disk already lives. See config.ProjectIndex.
11
+
12
+ Two questions are answered here and they are worth keeping apart. What kind of
13
+ work a process is doing, which is classify_cmd() and the rules around it. And
14
+ what still has a claim on it, which is Hold."""
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from .config import SESSION_VISIBLE
22
+ from .themes import PROFILE_SWATCHES
23
+
24
+
25
+ @dataclass
26
+
27
+
28
+ class Session:
29
+ kind: str
30
+ label: str
31
+ mb: int
32
+ window_id: int | None = None
33
+ tty: str | None = None
34
+ title: str = ""
35
+ profile: str | None = None
36
+ pids: list[int] = field(default_factory=list)
37
+ # What each pid was running when the scan saw it. For the audit log and
38
+ # the confirmation prompt: this is description, not identity.
39
+ cmds: dict[int, str] = field(default_factory=dict)
40
+ # When each pid started. This *is* identity. A scan can sit on screen for
41
+ # hours before someone presses `k`, and macOS recycles pids, so it is
42
+ # rechecked at kill time. See procs.still_the_same().
43
+ starts: dict[int, str] = field(default_factory=dict)
44
+
45
+ def to_dict(self) -> dict:
46
+ return {
47
+ "kind": self.kind,
48
+ "label": self.label,
49
+ "mb": self.mb,
50
+ "window_id": self.window_id,
51
+ "tty": self.tty,
52
+ "title": self.title,
53
+ "profile": self.profile,
54
+ }
55
+
56
+
57
+ @dataclass
58
+
59
+
60
+ class Project:
61
+ name: str
62
+ path: Path | None
63
+ sessions: list[Session] = field(default_factory=list)
64
+ profile: str = "Basic"
65
+
66
+ @property
67
+ def mb(self) -> int:
68
+ return sum(s.mb for s in self.sessions)
69
+
70
+ @property
71
+ def window_ids(self) -> list[int]:
72
+ seen: list[int] = []
73
+ for s in self.sessions:
74
+ if s.window_id is not None and s.window_id not in seen:
75
+ seen.append(s.window_id)
76
+ return seen
77
+
78
+ def to_dict(self) -> dict:
79
+ sw = PROFILE_SWATCHES.get(self.profile, PROFILE_SWATCHES["Basic"])
80
+ visible = self.sessions[:SESSION_VISIBLE]
81
+ hidden = self.sessions[SESSION_VISIBLE:]
82
+ return {
83
+ "name": self.name,
84
+ "path": str(self.path) if self.path else None,
85
+ "mb": self.mb,
86
+ "profile": self.profile,
87
+ "swatch": sw,
88
+ "window_ids": self.window_ids,
89
+ "window_count": len(self.window_ids),
90
+ "session_count": len(self.sessions),
91
+ "sessions": [s.to_dict() for s in visible],
92
+ "hidden_sessions": [s.to_dict() for s in hidden],
93
+ "hidden_count": len(hidden),
94
+ }
95
+
96
+
97
+ def tty_short(tty: str) -> str:
98
+ return tty.replace("/dev/", "") if tty else ""
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Hold:
103
+ """A live thing with a claim on a process, and why it has one.
104
+
105
+ The orphan sweep asks one question of every process it recognises: is
106
+ there still a way back to this. A hold is a yes, and the shape of the yes
107
+ matters, because the three that exist are not the same kind of fact:
108
+
109
+ app it belongs to a running application, so it was never a
110
+ terminal session at all. Quitting it is useless, the
111
+ application respawns it, and harmful, it force-quits part
112
+ of something live.
113
+ window a Terminal window has it. Somebody is sitting there.
114
+ pane a multiplexer pane has it. Somebody can sit there: a
115
+ detached session is still one `tmux attach` away, which is
116
+ exactly what an orphan is not.
117
+ multiplexer a multiplexer is running and could not be asked what it is
118
+ holding, so it is treated as holding everything under it.
119
+
120
+ That last one is the reason this is a record rather than the set of pids
121
+ it replaced. A set can say "held" and "not held" and cannot say "I could
122
+ not find out", so the code answered the third case by permanently treating
123
+ every multiplexer server as a pane. Naming it makes the fallback both
124
+ conservative and temporary: the moment tmux answers, the server stops
125
+ holding anything and its panes hold their own.
126
+
127
+ `why` is a sentence rather than a code because the desk shows it. It is
128
+ written to follow a count: "2 processes are ...".
129
+
130
+ `needs_tty` narrows a claim to the processes its holder is *showing*. It
131
+ exists for one measured shape: a tmux server that answered gives a pty to
132
+ everything it displays and to nothing it merely runs, so a `run-shell -b`
133
+ job comes back with no controlling terminal (measured: tty `??`) while
134
+ everything in a pane has one. Without it, removing the server's blanket
135
+ claim would also uncover a `display-popup` job, which is on somebody's
136
+ screen at the time.
137
+ """
138
+ pid: int
139
+ kind: str
140
+ why: str
141
+ needs_tty: bool = False
142
+
143
+
144
+ # Every kind, least specific first. Which claim a pid keeps when two of them
145
+ # reach it is decided by this order and not by which line ran last.
146
+ SPECIFIC = ("app", "window", "multiplexer", "shown", "pane")
147
+
148
+ # Holds worth telling the reader about: the ones whose holder is not itself on
149
+ # the desk. Every Terminal window is already a row, and an application is not
150
+ # workmap's business, so naming either would be noise. Everything a
151
+ # multiplexer holds is neither shown nor out of scope, which makes it the one
152
+ # case where "nothing here" and "hidden because something has it" look
153
+ # identical and are not. A kind not listed here says nothing, which is the
154
+ # right default for one nobody has thought about yet.
155
+ REPORTABLE_HOLDS = frozenset({"pane", "shown", "multiplexer"})
156
+
157
+
158
+ def strongest(seen: "Hold | None", fresh: Hold) -> Hold:
159
+ """Of two claims on one pid, the one that says more.
160
+
161
+ A pid can attract two. Terminal.app's own process is both a running
162
+ application and the emulator, and a tmux server inside another one's pane
163
+ is both a multiplexer nobody could ask and a pane somebody can reach.
164
+ Under the flat set of pids this replaced, the overlap was invisible and
165
+ harmless; now that each claim carries a sentence the desk shows, which one
166
+ survives is an answer on screen, and deciding it by which line of
167
+ build_projects() ran last is the trap two configured roots used to set.
168
+ """
169
+ if seen is None:
170
+ return fresh
171
+ return fresh if SPECIFIC.index(fresh.kind) > SPECIFIC.index(seen.kind) else seen
172
+
173
+
174
+ def ancestry(pid: int, parent_of: dict) -> "tuple[list[int], bool]":
175
+ """Every pid from this one upward, and whether the walk got to the top.
176
+
177
+ The top is pid 1 or pid 0. Reaching it means the table really did have
178
+ the whole line, so "nothing up there has a claim" is an answer. The walk
179
+ ends early in two other ways, and they are the same fact wearing two
180
+ hats: `ps` had no row for the next parent, or the links turned back on
181
+ themselves. Either way the table does not say what is above this process,
182
+ and the honest reading of that is not "nothing".
183
+
184
+ `ps` is a snapshot of a moving target. A parent that exits while the walk
185
+ is being generated leaves a child pointing at a row that never gets
186
+ emitted, so this is a race rather than an impossibility.
187
+ """
188
+ chain: list[int] = []
189
+ seen: set = set()
190
+ while pid and pid > 1 and pid not in seen:
191
+ seen.add(pid)
192
+ chain.append(pid)
193
+ pid = parent_of.get(pid, -1)
194
+ return chain, pid in (0, 1)
195
+
196
+
197
+ def holder(pid: int, holds: dict, parent_of: dict, *,
198
+ on_a_tty: bool = True) -> "Hold | None":
199
+ """What has a claim on this process, walking up from it. None if nothing.
200
+
201
+ The nearest claim wins, which is what makes its `why` true: a pane inside
202
+ a Terminal window is a better answer than the window.
203
+
204
+ `on_a_tty` is whether the process being asked about has a controlling
205
+ terminal. A `needs_tty` claim does not reach one that has none, and the
206
+ walk carries on past it rather than stopping, because a claim that does
207
+ not apply is not an answer.
208
+
209
+ None here means "found nothing on the way up", which is not the same as
210
+ "there is nothing". Ask `traceable()` before reading it as the second.
211
+ """
212
+ for up in ancestry(pid, parent_of)[0]:
213
+ hit = holds.get(up)
214
+ if hit is not None and (on_a_tty or not hit.needs_tty):
215
+ return hit
216
+ return None
217
+
218
+
219
+ def traceable(pid: int, parent_of: dict) -> bool:
220
+ """Did the walk above this process reach the top of the tree.
221
+
222
+ Shares `ancestry()` with `holder()` on purpose. Deciding "nobody holds
223
+ this" and deciding "the table could say" are two readings of one walk,
224
+ and two walks written separately are how `under_a_root()` and
225
+ `project_from_path()` drifted apart.
226
+ """
227
+ return ancestry(pid, parent_of)[1]
228
+
229
+
230
+ def executable_of(cmd: str) -> str:
231
+ """Best-effort executable path: everything before the first flag.
232
+
233
+ Split on " -" rather than whitespace, macOS executable paths are full of
234
+ spaces ("Codex (Renderer)"), so splitting on space would truncate them.
235
+ """
236
+ return cmd.split(" -", 1)[0].strip()
237
+
238
+
239
+ def _executable_names(cmd: str) -> tuple:
240
+ """The names this command's executable could be going by.
241
+
242
+ There are two readings of where the executable ends and neither is right
243
+ on its own, because `ps` does not quote:
244
+
245
+ /Users/ada smith/bin/tmux -L work the path runs past the first word
246
+ tmux new-session -d the span runs past the executable
247
+
248
+ executable_of() takes everything before the first flag, which is right for
249
+ the first and wrong for the second; the first word is right for the second
250
+ and wrong for the first. Telling them apart means asking the disk, and
251
+ these are the rules that are meant not to. So a rule that decides "leave
252
+ it alone" reads both, where being wrong costs a missed orphan, and a rule
253
+ that decides "this is a target" reads neither and goes through _subject().
254
+ """
255
+ tokens = cmd.split()
256
+ if not tokens:
257
+ return ()
258
+ first = _program_name(tokens[0])
259
+ span = _program_name(executable_of(cmd))
260
+ return (first,) if first == span else (first, span)
261
+
262
+
263
+ def is_gui_app(cmd: str) -> bool:
264
+ """Is this a piece of a running macOS application?
265
+
266
+ A GUI application's helper processes are never terminal orphans. They
267
+ have a live parent that respawns them the instant they die, so "quitting"
268
+ them is useless *and* harmful: it force-quits pieces of a running app.
269
+
270
+ Checking only the executable matters: ChatGPT.app's renderer carries
271
+ `--user-data-dir=.../Application Support/Codex --standard-schemes=...`,
272
+ whose literal `codex --` fragment matched the agent patterns below and got
273
+ six of its processes SIGKILLed on a loop.
274
+
275
+ An interpreter is the exception, because a `.app` around one is packaging
276
+ rather than an application. Every Python on macOS lives in
277
+ `Python.framework/.../Python.app/Contents/MacOS/Python`, including the one
278
+ at /usr/bin/python3, so treating that as an app made every Python dev
279
+ server invisible to the sweep. The tool was blind to a whole language's
280
+ worth of exactly what it exists to find.
281
+
282
+ That narrowing is safe because it is not what protects an app's helpers.
283
+ scan.build_projects() walks a candidate's ancestry looking for an owner,
284
+ and an interpreter a real application launched still has that application
285
+ above it. Checked against the live process table: of ~1050 processes only
286
+ five change answer here, and the two that are genuinely ChatGPT.app's
287
+ (`Contents/Resources/cua_node/bin/node`) stay owned through their parent.
288
+ """
289
+ if ".app/Contents/" not in executable_of(cmd):
290
+ return False
291
+ tokens = cmd.split()
292
+ program = _program_name(tokens[0]) if tokens else ""
293
+ return program not in INTERPRETERS
294
+
295
+
296
+ def multiplexer_name(cmd: str) -> str:
297
+ """Which terminal multiplexer this is, or "" if it is not one.
298
+
299
+ A tmux or screen pane is a session somebody is sitting in, exactly like a
300
+ tab, and the emulator cannot see it. Both of them daemonise their server
301
+ to pid 1 and give each pane a pty of their own, so a pane is neither on a
302
+ tty the driver reports nor descended from the emulator: the two things
303
+ that make a process somebody's both miss at once, and the sweep offers
304
+ live work for killing. Verified: a `vite` started in a pane under a
305
+ configured root was listed as an orphan and `workmap kill -n` named it,
306
+ while the pane was on screen and attached.
307
+
308
+ The name, not just yes or no, because only some of them can be asked what
309
+ they are holding and multiplexer.py has to know which it is looking at.
310
+ screen on macOS renames its own server process to `SCREEN` in capitals,
311
+ which is why this reads through _program_name rather than comparing the
312
+ token.
313
+
314
+ Read from the executable, like every other rule here that reads a command
315
+ line, so `screencapture` is not `screen`. Both spellings of it: see
316
+ _executable_names. Reading only executable_of() meant `tmux new-session
317
+ -d`, which is how a server is usually started, was not a multiplexer at
318
+ all, because the subcommand carries no leading dash and so was swallowed
319
+ into the executable. Every pane under such a server was offered for
320
+ killing. This one decides "leave it alone", where being wrong the other
321
+ way costs a missed orphan.
322
+ """
323
+ for name in _executable_names(cmd):
324
+ if name in MULTIPLEXERS:
325
+ return name
326
+ return ""
327
+
328
+
329
+ def is_multiplexer(cmd: str) -> bool:
330
+ return bool(multiplexer_name(cmd))
331
+
332
+
333
+ MULTIPLEXERS = frozenset({
334
+ "tmux", "screen", "zellij", "dtach", "abduco", "byobu",
335
+ })
336
+
337
+ # A shell's arguments are an opaque program, not a command line we can read.
338
+ # `/bin/zsh -c source ~/.claude/shell-snapshots/snapshot-zsh-….sh && …` is a
339
+ # shell, not an agent. Matching "claude" anywhere in that string is how a
340
+ # plain shell got classified as a running claude session.
341
+ SHELLS = frozenset({"sh", "bash", "zsh", "dash", "ksh", "csh", "tcsh", "fish"})
342
+
343
+ # Programs that run *another* program named by their first non-flag argument.
344
+ INTERPRETERS = frozenset({
345
+ "node", "nodejs", "bun", "deno", "python", "python2", "python3", "ruby",
346
+ "perl", "php", "env",
347
+ })
348
+
349
+ # Package runners: `npm run dev`, `npx vite`, `pnpm exec astro`.
350
+ RUNNERS = frozenset({"npm", "npx", "pnpm", "yarn", "bun", "deno"})
351
+ RUNNER_VERBS = frozenset({"run", "exec", "x", "run-script", "task"})
352
+
353
+ AGENTS = {"claude": "claude", "codex": "codex", "cursor-agent": "cursor-agent"}
354
+ DEV_TOOLS = {
355
+ "vite": "vite",
356
+ "esbuild": "esbuild",
357
+ "astro": "astro",
358
+ "next": "node-dev",
359
+ "next-server": "node-dev",
360
+ "webpack": "node-dev",
361
+ "webpack-dev-server": "node-dev",
362
+ "rollup": "node-dev",
363
+ "parcel": "node-dev",
364
+ "nodemon": "node-dev",
365
+ "wrangler": "node-dev",
366
+ }
367
+
368
+ # Never a target, however it was invoked. Matched loosely on purpose, see
369
+ # _is_self() for why this one rule gets to look at the whole command line.
370
+ SELF = frozenset({"workmap", "devstack"})
371
+
372
+ _SCRIPT_EXT = (".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".py", ".rb")
373
+
374
+
375
+ def _program_name(token: str) -> str:
376
+ """The name a path or bare word refers to, minus decoration.
377
+
378
+ `/opt/homebrew/bin/node` → node, `-zsh` → zsh, `./bin/vite.js` → vite,
379
+ `…/node_modules/.bin/astro` → astro.
380
+ """
381
+ name = token.rsplit("/", 1)[-1].lstrip("-")
382
+ for ext in _SCRIPT_EXT:
383
+ if name.endswith(ext):
384
+ name = name[: -len(ext)]
385
+ break
386
+ return name.lower()
387
+
388
+
389
+ def _package_of(token: str) -> str:
390
+ """The npm package a script path belongs to, if it names one.
391
+
392
+ `…/node_modules/wrangler/wrangler-dist/cli.js` is wrangler, not "cli".
393
+ The last `node_modules/` wins: it is the innermost package that owns
394
+ the file.
395
+ """
396
+ marker = "/node_modules/"
397
+ idx = token.rfind(marker)
398
+ if idx < 0:
399
+ return ""
400
+ rest = token[idx + len(marker):].split("/")
401
+ if not rest or not rest[0]:
402
+ return ""
403
+ if rest[0] == ".bin":
404
+ return rest[1].lower() if len(rest) > 1 else ""
405
+ return rest[0].lower()
406
+
407
+
408
+ def _names_self(token: str) -> bool:
409
+ """Does this argv token *name* workmap, rather than merely pass through it?
410
+
411
+ The name a token refers to is its last path segment. `python3 -m
412
+ workmap.cli` names the module, so the dotted head counts too.
413
+ """
414
+ name = _program_name(token)
415
+ return name in SELF or name.split(".", 1)[0] in SELF
416
+
417
+
418
+ def _is_self(cmd: str) -> bool:
419
+ """Is this workmap (or the devstack it drives) in any form?
420
+
421
+ Deliberately the one rule that scans every token rather than only the
422
+ executable: this decides "leave it alone", so a false positive costs a
423
+ missed orphan and a false negative costs the tool killing its own process
424
+ tree. `npx workmap` has to match, and it is not the executable.
425
+
426
+ What it reads of each token is the name that token refers to, not every
427
+ directory the token passes through. Those are different questions, and
428
+ reading the second one is the third outing of the mistake that once had
429
+ ChatGPT.app classified as codex: `workmap` is itself a project directory
430
+ here, so a dev server started in it carried our name in its path and was
431
+ treated as us. Its whole Terminal window dropped off the desk, and it
432
+ could never be swept.
433
+ """
434
+ return any(_names_self(token) for token in cmd.split())
435
+
436
+
437
+ def is_self_process(cmd: str) -> bool:
438
+ """Is this process workmap itself?
439
+
440
+ Narrower than _is_self(), and the difference is the point. _is_self()
441
+ answers "never make this a target", where being wrong costs one missed
442
+ orphan, so it reads every token. This answers "this is the desk window,
443
+ leave it off the map", where being wrong costs a live Terminal window
444
+ disappearing along with the memory it is holding, so it reads only the
445
+ argv positions any rule here is allowed to read: the executable, and the
446
+ one argument an interpreter or a package runner is running.
447
+
448
+ `npm run dev --prefix ~/dev/workmap` names us in an argument and is not
449
+ us.
450
+ """
451
+ program, runs = _subject(cmd)
452
+ if any(_names_self(name) for name in (program, runs) if name):
453
+ return True
454
+ # `python3 share/workmap/tui.py` runs a module of ours from a checkout.
455
+ # The one directory that counts is the one holding the script, which is
456
+ # the package it belongs to, and only for a Python file: a `.js` sitting
457
+ # in a directory called workmap is somebody's project, not our package.
458
+ if program in INTERPRETERS:
459
+ script = _script_of(cmd.split()[1:])
460
+ if script.endswith(".py"):
461
+ parts = script.rsplit("/", 2)
462
+ if len(parts) > 1 and _names_self(parts[-2]):
463
+ return True
464
+ return False
465
+
466
+
467
+ def _subject(cmd: str) -> tuple[str, str]:
468
+ """(program, what that program was asked to run).
469
+
470
+ Both are plain names taken from argv positions we can defend: the
471
+ executable, and the first non-flag argument of an interpreter or package
472
+ runner. Never a substring of an arbitrary argument.
473
+ """
474
+ tokens = cmd.split()
475
+ if not tokens:
476
+ return "", ""
477
+ program = _program_name(tokens[0])
478
+ args = tokens[1:]
479
+
480
+ if program in RUNNERS:
481
+ rest = _without_flag_values(args)
482
+ if rest and rest[0].lower() in RUNNER_VERBS:
483
+ rest = rest[1:]
484
+ return program, (_program_name(rest[0]) if rest else "")
485
+
486
+ if program in INTERPRETERS:
487
+ span = _script_words(args)
488
+ if not span:
489
+ return program, ""
490
+ # The shortest prefix that names an npm package wins. Taking the whole
491
+ # span as one path made `node .../.bin/astro dev` a script called
492
+ # "astro dev", which is not a name anything matches, so a dev server
493
+ # invoked with a subcommand classified as nothing and was never swept.
494
+ # The span still has to be allowed to be long, because a path can
495
+ # contain spaces and ps does not quote: reading only the first word of
496
+ # "/Users/ada smith/dev/app/node_modules/.bin/vite" finds "ada".
497
+ for n in range(1, len(span) + 1):
498
+ package = _package_of(" ".join(span[:n]))
499
+ if package:
500
+ return program, package
501
+ return program, _program_name(" ".join(span))
502
+
503
+ return program, ""
504
+
505
+
506
+ def _without_flag_values(args: list[str]) -> list[str]:
507
+ """Arguments with the flags, and the values they take, removed.
508
+
509
+ Dropping only the tokens that start with a dash leaves the *value* of the
510
+ ones that take one, and then reads it as the thing being run: `npm
511
+ --prefix /opt/vite ci` was classified as a running vite, which is a rule
512
+ that decides "this IS a target" reading a flag value. That is the mistake
513
+ this file keeps finding, so a bare flag takes the word after it with it.
514
+ A flag written with an `=` carries its own value and takes nothing.
515
+ """
516
+ out: list[str] = []
517
+ skip = False
518
+ for tok in args:
519
+ if skip:
520
+ skip = False
521
+ continue
522
+ if tok.startswith("-"):
523
+ skip = "=" not in tok and tok != "--"
524
+ continue
525
+ out.append(tok)
526
+ return out
527
+
528
+
529
+ # Interpreter flags whose value names *another* module, which is not the
530
+ # program being run. Everything else a `node` flag can be is either a boolean
531
+ # or a number, so reading the word after it as the script is only wrong for
532
+ # these. Kept as a list rather than as "the word after any flag", because
533
+ # `node --experimental-vm-modules app.js` is far commoner than any of them and
534
+ # that rule would read `app.js` as the flag's value.
535
+ _MODULE_FLAGS = frozenset({
536
+ "-r", "--require", "--import", "--loader", "--experimental-loader",
537
+ })
538
+
539
+
540
+ def _script_words(args: list[str]) -> list[str]:
541
+ """The words of the path an interpreter was asked to run.
542
+
543
+ Runs to the next flag rather than the next word, because a path can
544
+ contain spaces. Empty when it was given no script: `-c <code>` is an
545
+ inline program, not something we can name.
546
+ """
547
+ skip = False
548
+ for i, tok in enumerate(args):
549
+ if skip:
550
+ skip = False
551
+ continue
552
+ if tok.startswith("-"):
553
+ if tok == "-c":
554
+ return []
555
+ skip = tok in _MODULE_FLAGS
556
+ continue
557
+ rest = args[i:]
558
+ end = next((j for j, t in enumerate(rest) if t.startswith("-")),
559
+ len(rest))
560
+ return rest[:end]
561
+ return []
562
+
563
+
564
+ def _script_of(args: list[str]) -> str:
565
+ """_script_words joined back into a path."""
566
+ return " ".join(_script_words(args))
567
+
568
+
569
+ def classify_cmd(cmd: str) -> str | None:
570
+ """What kind of work this process is, or None if it is not ours to track.
571
+
572
+ Reads the executable and (for interpreters and package runners) the one
573
+ argument naming what they run. It never searches the whole command line:
574
+ arguments carry paths, cache directories and flag values that collide with
575
+ every tool name worth matching. Two live examples this rule exists for:
576
+
577
+ /bin/zsh -c source ~/.claude/shell-snapshots/… → a shell, not claude
578
+ …/Codex (Renderer) --user-data-dir=…/Codex --standard-schemes=…
579
+ → a GUI app, not codex
580
+ """
581
+ if is_gui_app(cmd): # subsumes the old per-app Cursor exclusion
582
+ return None
583
+ if _is_self(cmd):
584
+ return None
585
+
586
+ program, runs = _subject(cmd)
587
+ if not program or program in SHELLS:
588
+ # A shell's argument is a program in another language. Whatever it
589
+ # spawns shows up in the process table on its own and is classified
590
+ # there, so there is nothing to lose by declining to guess here.
591
+ return None
592
+
593
+ for name in (program, runs):
594
+ if name in AGENTS:
595
+ return AGENTS[name]
596
+ for name in (program, runs):
597
+ if name in DEV_TOOLS:
598
+ return DEV_TOOLS[name]
599
+
600
+ if program in RUNNERS:
601
+ return f"{program} {runs}" if runs else None
602
+ return None
603
+
604
+
605
+ SPINNERS = "✳✱*·•◦⠂⠐←◂◀▷▹►"
606
+
607
+ # Leading spinner glyphs, and the spaces between them. Only those: the class
608
+ # in front of the glyph used to be `[\W_\d]*`, which is every digit and every
609
+ # punctuation mark, so anything a title opened with was eaten as long as a
610
+ # glyph turned up later on the line. "#42 • fix the parser" arrived as "fix
611
+ # the parser" with the issue number gone, while "v1.2.3 • release" kept all of
612
+ # itself, the only difference being that one starts with a letter. Whatever
613
+ # the rule is, it cannot be that.
614
+ _LEADING_NOISE = re.compile(rf"^(?:\s*[{SPINNERS}])+\s*")
615
+
616
+
617
+ def clean_label(label: str) -> str:
618
+ """Strip spinner glyphs and the space around them from agent titles."""
619
+ s = _LEADING_NOISE.sub("", label.strip())
620
+ s = re.sub(r"\s+", " ", s).strip()
621
+ if s in ("-zsh", "zsh"):
622
+ return "shell"
623
+ return s
624
+
625
+
626
+ # The agent names worth collapsing a whole title down to, and what to show
627
+ # for each. `cursor-agent` and `cursor` are one tool under two spellings.
628
+ AGENT_TITLES = {"claude": "claude", "codex": "codex",
629
+ "cursor": "cursor", "cursor-agent": "cursor"}
630
+
631
+
632
+ def short_tool(label: str, kind: str) -> str:
633
+ """A few words naming what is in this tab, for the title workmap sets.
634
+
635
+ The agent name is read from the *first word* of the label and not from
636
+ anywhere inside it. A window title is written by the agent sitting in that
637
+ window, which makes it the least trustworthy text this tool reads, and
638
+ searching all of it answered "claude" for "fix the unclaudeable bug" and
639
+ "cursor" for "precursor analysis". Same rule as classify_cmd(), for the
640
+ same reason: a name that appears somewhere in a string is not the name of
641
+ the thing.
642
+
643
+ A background service is answered before the agent names are consulted at
644
+ all. Its label is already the answer classify_cmd() worked out, so `npm
645
+ run claude-watch` came back correctly as "npm claude-watch" and was then
646
+ relabelled "claude", naming the wrong program on the one line somebody
647
+ reads before deciding to quit it.
648
+ """
649
+ if kind == "bg":
650
+ return clean_label(label).removesuffix(" (bg)").strip() or "service"
651
+ cleaned = clean_label(label)
652
+ words = cleaned.split()
653
+ if words and words[0].lower() in AGENT_TITLES:
654
+ return AGENT_TITLES[words[0].lower()]
655
+ if cleaned == "shell":
656
+ return "shell"
657
+ # task-style title from the agent, keep it short
658
+ if len(cleaned) > 42:
659
+ return cleaned[:41] + "…"
660
+ return cleaned
661
+
662
+
663
+ def plural(n: int, noun: str) -> str:
664
+ """"1 orphan", "3 orphans", "3 processes".
665
+
666
+ Cheaper than writing "orphan(s)", which asks the reader to do the
667
+ agreement in their head on every line it appears on, and which appeared on
668
+ every confirmation prompt in the tool.
669
+ """
670
+ if n == 1:
671
+ return f"{n} {noun}"
672
+ ending = "es" if noun.endswith(("s", "x", "z", "ch", "sh")) else "s"
673
+ return f"{n} {noun}{ending}"
674
+
675
+
676
+ def held_note(held: list) -> str:
677
+ """One line saying why something running is not on the desk. "" if none.
678
+
679
+ The desk could say "nothing is running without a window" and be describing
680
+ two different machines: one with nothing to quit, and one where a tmux
681
+ pane has the thing you were looking for. They read identically, and only
682
+ one of them means everything is fine.
683
+
684
+ A multiplexer that could not be asked comes first when both are true. A
685
+ pane holding something is the tool working; not being able to ask is the
686
+ tool admitting it cannot see, which is the more urgent of the two and the
687
+ only one that gets better if you try again.
688
+ """
689
+ if not held:
690
+ return ""
691
+ unasked = [h for h in held if h.kind == "multiplexer"]
692
+ if unasked:
693
+ names = sorted({h.why.split(",", 1)[0].replace("inside ", "")
694
+ for h in unasked})
695
+ which = " and ".join(names) or "a terminal multiplexer"
696
+ verb = "is" if len(unasked) == 1 else "are"
697
+ return (f"{plural(len(unasked), 'process')} {verb} inside {which}, "
698
+ f"which could not say what it is holding.")
699
+ verb, them = ("is", "it") if len(held) == 1 else ("are", "they")
700
+ return (f"{plural(len(held), 'process')} {verb} open in a tmux pane, "
701
+ f"so {them} {verb} not orphaned.")
702
+
703
+
704
+ def fmt_mb(mb: int) -> str:
705
+ if mb >= 1024:
706
+ return f"{mb/1024:.1f}G"
707
+ return f"{mb}M"
708
+
709
+
710
+ def fmt_mem(value) -> str:
711
+ """Format system figures that arrive as str/float (sysctl gives "6498.25").
712
+
713
+ Same units as fmt_mb so every size on screen reads the same way.
714
+ """
715
+ try:
716
+ mb = float(value)
717
+ except (TypeError, ValueError):
718
+ return str(value)
719
+ return f"{mb / 1024:.1f}G" if mb >= 1024 else f"{int(round(mb))}M"