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/__init__.py +30 -0
- workmap/__main__.py +15 -0
- workmap/actions.py +350 -0
- workmap/audit.py +153 -0
- workmap/cli.py +789 -0
- workmap/config.py +589 -0
- workmap/demo.py +94 -0
- workmap/drivers/__init__.py +93 -0
- workmap/drivers/apple_terminal.py +578 -0
- workmap/layout.py +64 -0
- workmap/model.py +719 -0
- workmap/multiplexer.py +201 -0
- workmap/procs.py +504 -0
- workmap/scan.py +413 -0
- workmap/setup.py +400 -0
- workmap/shell.py +117 -0
- workmap/terminal.py +50 -0
- workmap/themes.py +45 -0
- workmap/tui/__init__.py +6 -0
- workmap/tui/app.py +1090 -0
- workmap/tui/onboarding.py +266 -0
- workmap/tui/text.py +156 -0
- workmap/tui/widgets.py +189 -0
- workmap-0.1.0.dist-info/METADATA +258 -0
- workmap-0.1.0.dist-info/RECORD +28 -0
- workmap-0.1.0.dist-info/WHEEL +4 -0
- workmap-0.1.0.dist-info/entry_points.txt +2 -0
- workmap-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|