flowviant 0.63.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/claude.mjs +19 -0
- package/bin/lib/fleet.mjs +2 -39
- package/bin/lib/listeners.mjs +39 -7
- package/bin/lib/placeLock.mjs +106 -0
- package/bin/lib/processes.mjs +202 -0
- package/bin/lib/work.mjs +102 -458
- package/package.json +1 -1
- package/bin/lib/devResolve.mjs +0 -167
- package/bin/lib/devServer.mjs +0 -306
package/bin/lib/claude.mjs
CHANGED
|
@@ -359,6 +359,25 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
359
359
|
const child = spawn(rt.bin, args, {
|
|
360
360
|
cwd,
|
|
361
361
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
362
|
+
/**
|
|
363
|
+
* ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
|
|
364
|
+
*
|
|
365
|
+
* Everything the CLI spawns inherits this pgid and KEEPS it through
|
|
366
|
+
* `nohup` and `setsid` — which is exactly when attribution by descendancy
|
|
367
|
+
* fails, because reparenting to init breaks the ppid chain the moment a
|
|
368
|
+
* process becomes long-running. `processes.mjs` reads the group; the
|
|
369
|
+
* Workbench renders it.
|
|
370
|
+
*
|
|
371
|
+
* TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
|
|
372
|
+
* CHILD and never the group. Signalling the group would kill the dev
|
|
373
|
+
* server the driver started every time the daemon restarts — including
|
|
374
|
+
* on an ordinary auto-update, unattended — which is the outcome the
|
|
375
|
+
* deleted dev-run supervisor spent a whole registry avoiding. Flowviant
|
|
376
|
+
* does not manage those processes; it reports them.
|
|
377
|
+
*
|
|
378
|
+
* Not `unref`'d: the daemon must still wait on this turn.
|
|
379
|
+
*/
|
|
380
|
+
detached: true,
|
|
362
381
|
// Only ADDS to the environment (the worker token, for runtimes that read
|
|
363
382
|
// it from there). Never replaces it: the CLI's own credentials live in
|
|
364
383
|
// this environment, and handing it a curated one signs it out.
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -91,8 +91,7 @@ import { repoState } from './repoState.mjs';
|
|
|
91
91
|
async function fetchRoster(
|
|
92
92
|
haveIds,
|
|
93
93
|
livePreviewSessionIds = [],
|
|
94
|
-
heldSessionIds = []
|
|
95
|
-
liveDevRunSessionIds = []
|
|
94
|
+
heldSessionIds = []
|
|
96
95
|
) {
|
|
97
96
|
const url = new URL(FLEET_URL);
|
|
98
97
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
@@ -123,11 +122,6 @@ async function fetchRoster(
|
|
|
123
122
|
// the server still calls live is a 530 on somebody's phone. Always set, even
|
|
124
123
|
// empty: '' means "serving none", absent would mean "an older daemon".
|
|
125
124
|
url.searchParams.set('pv', livePreviewSessionIds.join(','));
|
|
126
|
-
// The dev runs this machine is still carrying. Same rule as `pv`: always set,
|
|
127
|
-
// even empty — '' means "running none", absent would mean "an older daemon",
|
|
128
|
-
// and a run the server still calls running with nothing behind it is a chip
|
|
129
|
-
// that says Serving over a dead port.
|
|
130
|
-
url.searchParams.set('dr', liveDevRunSessionIds.join(','));
|
|
131
125
|
// The sessions this daemon holds a worktree for. Its LEASE on each renews
|
|
132
126
|
// here — one beat, no extra endpoint, and the server can tell "this daemon is
|
|
133
127
|
// still serving that tab" from "it went away" within a reconcile interval
|
|
@@ -514,11 +508,6 @@ export async function runFleetDaemon() {
|
|
|
514
508
|
// Kill any preview dev-server/tunnel groups a previously-crashed daemon left
|
|
515
509
|
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
516
510
|
reapOrphanPreviews((m) => info(m));
|
|
517
|
-
// Dev servers are ADOPTED rather than reaped when their session is still
|
|
518
|
-
// live: a self-update or a same-repo takeover leaves them running on purpose,
|
|
519
|
-
// and the successor must supervise them or the row says running while nothing
|
|
520
|
-
// owns the process. The activeWorkSessions list is not known yet at startup,
|
|
521
|
-
// so the first reconcile does the adopting — see `adoptDevRuns`.
|
|
522
511
|
|
|
523
512
|
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
524
513
|
// so Ctrl+C mid-task never loses local work. Keyed per repo path.
|
|
@@ -642,20 +631,6 @@ export async function runFleetDaemon() {
|
|
|
642
631
|
// commanded stop.
|
|
643
632
|
//
|
|
644
633
|
// A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
|
|
645
|
-
// stated limitation rather than an oversight. The takeover asks the holder
|
|
646
|
-
// to stand down with SIGTERM, which is byte-for-byte what `systemctl stop`
|
|
647
|
-
// sends, so this handler cannot tell "another daemon is about to serve this
|
|
648
|
-
// repo in one second" from "this box is going down". Killing is the safe
|
|
649
|
-
// reading: the wrong guess in the other direction leaves dev servers
|
|
650
|
-
// running with nothing supervising them until a reboot. The cost is that
|
|
651
|
-
// re-running `flowviant` in your own repo restarts the app you were
|
|
652
|
-
// watching, and the successor's `adoptDevRuns` finds nothing.
|
|
653
|
-
//
|
|
654
|
-
// The SELF-UPDATE re-exec — the common unattended case — does NOT reach
|
|
655
|
-
// teardown, so its runs survive and are adopted, which is the half of
|
|
656
|
-
// "consistently running" this delivers today. Distinguishing a takeover
|
|
657
|
-
// would need the successor to mark the lock before it signals.
|
|
658
|
-
shutdownDevRuns(true);
|
|
659
634
|
};
|
|
660
635
|
process.on('SIGINT', () => {
|
|
661
636
|
console.log('');
|
|
@@ -833,11 +808,6 @@ export async function runFleetDaemon() {
|
|
|
833
808
|
livePreviewIds,
|
|
834
809
|
retirePreviews,
|
|
835
810
|
shutdownPreviews,
|
|
836
|
-
processDevRunJobs,
|
|
837
|
-
liveDevRunIds,
|
|
838
|
-
retireDevRuns,
|
|
839
|
-
shutdownDevRuns,
|
|
840
|
-
adoptDevRuns,
|
|
841
811
|
retireWorkSessions,
|
|
842
812
|
reportWorktrees,
|
|
843
813
|
shutdownWork,
|
|
@@ -1443,7 +1413,7 @@ export async function runFleetDaemon() {
|
|
|
1443
1413
|
for (;;) {
|
|
1444
1414
|
let roster;
|
|
1445
1415
|
try {
|
|
1446
|
-
roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds()
|
|
1416
|
+
roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
|
|
1447
1417
|
} catch (e) {
|
|
1448
1418
|
if (e.auth) {
|
|
1449
1419
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
@@ -1572,12 +1542,6 @@ export async function runFleetDaemon() {
|
|
|
1572
1542
|
// `git worktree remove` would happily pull the directory out from under a
|
|
1573
1543
|
// running node process, which then serves bytes from open file handles in
|
|
1574
1544
|
// a directory that no longer exists, with no error anywhere.
|
|
1575
|
-
// ADOPT BEFORE RETIRING. A run left alive by a self-update or a takeover
|
|
1576
|
-
// must be re-attached before the retire pass can decide it is unowned;
|
|
1577
|
-
// doing it the other way round would kill exactly the processes this
|
|
1578
|
-
// feature exists to keep.
|
|
1579
|
-
adoptDevRuns(roster.activeWorkSessions);
|
|
1580
|
-
retireDevRuns(roster.activeWorkSessions);
|
|
1581
1545
|
// A session another daemon on this credential is serving is NOT a closed
|
|
1582
1546
|
// tab. Without this the daemon that lost the lease removes the worktree the
|
|
1583
1547
|
// winner is working in — absence would mean "somebody else won" instead of
|
|
@@ -1596,7 +1560,6 @@ export async function runFleetDaemon() {
|
|
|
1596
1560
|
// credential are both handed this array, and both opening a tunnel strands
|
|
1597
1561
|
// a public hostname nobody can settle.
|
|
1598
1562
|
processPreviewJobs(roster.previewJobs);
|
|
1599
|
-
processDevRunJobs(roster.devRunJobs);
|
|
1600
1563
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1601
1564
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1602
1565
|
// themselves from a browser, relayed. After retirement so a directory that
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const MAX_PIDS = 4000;
|
|
|
40
40
|
* twenty is somebody's docker-compose and the extra rows say nothing. */
|
|
41
41
|
const MAX_ROWS = 8;
|
|
42
42
|
/** Longest process label we relay. */
|
|
43
|
-
const MAX_LABEL =
|
|
43
|
+
const MAX_LABEL = 40;
|
|
44
44
|
|
|
45
45
|
// ── /proc/net/tcp parsing (linux) ──────────────────────────────────────────
|
|
46
46
|
|
|
@@ -83,15 +83,47 @@ function listeningByInode() {
|
|
|
83
83
|
return out;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* WHAT TO CALL A LISTENING PROCESS.
|
|
88
|
+
*
|
|
89
|
+
* This used to be `basename(argv[0])`, which names the RUNTIME and not the
|
|
90
|
+
* program — so half the rows in a JavaScript repo read `node` and told the
|
|
91
|
+
* reader nothing about which of their servers this was. The question that
|
|
92
|
+
* changed it: "a non developer wouldnt know what node is or workerd."
|
|
93
|
+
*
|
|
94
|
+
* THE FIX IS NOT A LOOKUP TABLE. Mapping `workerd` → "Cloudflare Worker" is
|
|
95
|
+
* the `DEV_ARGV0` shape: a hardcoded list of tools that is wrong for every
|
|
96
|
+
* stack nobody enumerated, needing a release per ecosystem forever. What is
|
|
97
|
+
* always available instead is argv[1] — the thing the runtime was asked to
|
|
98
|
+
* run — so `node …/node_modules/.bin/vite` becomes `node vite` and
|
|
99
|
+
* `node server.js` becomes `node server.js`. No list, works on every stack.
|
|
100
|
+
*
|
|
101
|
+
* A FLAG IS SKIPPED rather than guessed past: `python3 -m http.server` keeps
|
|
102
|
+
* `python3` instead of claiming to be called `-m`. Only the first two argv
|
|
103
|
+
* elements are ever read — enough to name the program, far short of the whole
|
|
104
|
+
* command line, which is the thing this deliberately does not relay.
|
|
105
|
+
*
|
|
106
|
+
* And this is still only a MEASUREMENT. The name a person gives a share lives
|
|
107
|
+
* on `session_previews.label`, is written by a human PATCH, and is what a
|
|
108
|
+
* teammate actually reads — no amount of argv produces "Storefront".
|
|
109
|
+
*/
|
|
110
|
+
export function labelFromArgv(argv) {
|
|
111
|
+
const base = (v) => (v || '').split('/').pop() || v || '';
|
|
112
|
+
const head = base(argv?.[0]);
|
|
113
|
+
if (!head) return null;
|
|
114
|
+
// argv[1] only when it NAMES something rather than configuring it. A `-`
|
|
115
|
+
// prefix is the one universally reliable tell, and skipping is the honest
|
|
116
|
+
// answer — `python3 -m http.server` keeps `python3` rather than claiming to
|
|
117
|
+
// be called `-m`.
|
|
118
|
+
const next = argv?.[1] && !argv[1].startsWith('-') ? base(argv[1]) : '';
|
|
119
|
+
const label = next && next !== head ? `${head} ${next}` : head;
|
|
120
|
+
return label.slice(0, MAX_LABEL) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
86
123
|
function labelFor(pid) {
|
|
87
124
|
try {
|
|
88
125
|
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
89
|
-
|
|
90
|
-
// The basename only. A full argv is the driver's command line, which can
|
|
91
|
-
// carry a token in an inline env assignment — and the whole argv is never
|
|
92
|
-
// what a reader needs to recognise their own dev server.
|
|
93
|
-
const base = first.split('/').pop() || first;
|
|
94
|
-
return base.slice(0, MAX_LABEL) || null;
|
|
126
|
+
return labelFromArgv(raw.split('\0').filter(Boolean));
|
|
95
127
|
} catch {
|
|
96
128
|
return null;
|
|
97
129
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PLACE LOCK — several turns may run in one directory; Flowviant's own git
|
|
3
|
+
* may not run beside any of them.
|
|
4
|
+
*
|
|
5
|
+
* Its own module because it is the only concurrency PRIMITIVE in the daemon and
|
|
6
|
+
* the only one whose failure mode is invisible: a lock that grants too much
|
|
7
|
+
* shows up as corrupted work weeks later, and one that grants too little shows
|
|
8
|
+
* up as "ship did nothing". Inside `createWorkManager` it could not be tested
|
|
9
|
+
* at all.
|
|
10
|
+
*/
|
|
11
|
+
export function createPlaceLock() {
|
|
12
|
+
/**
|
|
13
|
+
* THE PLACE LOCK — turns run CONCURRENTLY; Flowviant's own git does not.
|
|
14
|
+
*
|
|
15
|
+
* This was `chainFor`, a strict serial queue per place: every turn waited for
|
|
16
|
+
* the one before it, so two tabs in one directory took turns and — because
|
|
17
|
+
* every tab's place defaulted to the checkout — no two tabs in the product
|
|
18
|
+
* ever ran at the same time. The driver's instruction ended it: "i feel like
|
|
19
|
+
* we shouldnt need to manage parallelism on flowviant, the ai clis should
|
|
20
|
+
* intrinsictly factor that in."
|
|
21
|
+
*
|
|
22
|
+
* It is a READERS-WRITER lock now, and the split is exactly where the
|
|
23
|
+
* argument lands. A TURN is a READER: several may run in one place at once,
|
|
24
|
+
* because coordinating two agents editing a tree is their job and they are
|
|
25
|
+
* better at it than a queue is — measured, three agents on disjoint files
|
|
26
|
+
* lost nothing across 120 concurrent writes. A SHIP is a WRITER: it folds and
|
|
27
|
+
* merges with git, in that same directory, and the CLIs cannot coordinate
|
|
28
|
+
* with it because they do not know it exists. That is the one piece of
|
|
29
|
+
* concurrency Flowviant still owns, so it is the one piece it still manages.
|
|
30
|
+
*
|
|
31
|
+
* WRITER PREFERENCE, deliberately: once a ship is waiting, later turns queue
|
|
32
|
+
* behind it. Without that a busy place could starve a ship indefinitely, and
|
|
33
|
+
* "ship it" would appear to do nothing for as long as anybody kept typing.
|
|
34
|
+
*
|
|
35
|
+
* THE SHIP SIDE ALSO FIXES A LATENT BUG. Ship used to take `chainFor` on the
|
|
36
|
+
* SESSION id while turns took it on the PLACE, under a comment claiming a
|
|
37
|
+
* ship "must not run git in this worktree while a turn's CLI is live in it".
|
|
38
|
+
* Those are different keys the moment a place is set — which was every tab —
|
|
39
|
+
* so the two never shared a chain and the guarantee was not held. Both sides
|
|
40
|
+
* key on the place now.
|
|
41
|
+
*
|
|
42
|
+
* The cross-process pid lock (`flowviant-turn.lock`) is UNCHANGED and stays
|
|
43
|
+
* per-place: it is a crash backstop for an orphaned CLI a dead daemon left
|
|
44
|
+
* behind, not the parallelism policy. It is more conservative than this lock
|
|
45
|
+
* — two sessions sharing one place still serialize across processes — which
|
|
46
|
+
* costs nothing in practice, since a place holds one session unless somebody
|
|
47
|
+
* deliberately points a second one at it.
|
|
48
|
+
*/
|
|
49
|
+
const placeLocks = new Map(); // placeId -> { readers, writing, waiters: [] }
|
|
50
|
+
|
|
51
|
+
const pumpPlace = (id) => {
|
|
52
|
+
const st = placeLocks.get(id);
|
|
53
|
+
if (!st) return;
|
|
54
|
+
while (st.waiters.length) {
|
|
55
|
+
const next = st.waiters[0];
|
|
56
|
+
if (next.write) {
|
|
57
|
+
// A writer runs only in a quiet place, and blocks everything behind it.
|
|
58
|
+
if (st.readers === 0 && !st.writing) {
|
|
59
|
+
st.waiters.shift();
|
|
60
|
+
st.writing = true;
|
|
61
|
+
next.go();
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (st.writing) break;
|
|
66
|
+
st.waiters.shift();
|
|
67
|
+
st.readers += 1;
|
|
68
|
+
next.go();
|
|
69
|
+
}
|
|
70
|
+
// Drop the entry when the place goes quiet, so the map cannot grow for the
|
|
71
|
+
// process lifetime.
|
|
72
|
+
if (!st.writing && st.readers === 0 && st.waiters.length === 0) placeLocks.delete(id);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run `fn` holding the place's lock. `write: true` is exclusive.
|
|
77
|
+
*
|
|
78
|
+
* `.finally` rather than a try/catch that swallows: one rejected turn must
|
|
79
|
+
* release the lock but must NOT be turned into a success — the settle
|
|
80
|
+
* contract above is what guarantees a turn is answered, and hiding a throw
|
|
81
|
+
* here would let a turn fail silently while holding nothing.
|
|
82
|
+
*/
|
|
83
|
+
const inPlace = async (placeId, write, fn) => {
|
|
84
|
+
let st = placeLocks.get(placeId);
|
|
85
|
+
if (!st) {
|
|
86
|
+
st = { readers: 0, writing: false, waiters: [] };
|
|
87
|
+
placeLocks.set(placeId, st);
|
|
88
|
+
}
|
|
89
|
+
await new Promise((go) => {
|
|
90
|
+
st.waiters.push({ write, go });
|
|
91
|
+
pumpPlace(placeId);
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
return await fn();
|
|
95
|
+
} finally {
|
|
96
|
+
const cur = placeLocks.get(placeId);
|
|
97
|
+
if (cur) {
|
|
98
|
+
if (write) cur.writing = false;
|
|
99
|
+
else cur.readers = Math.max(0, cur.readers - 1);
|
|
100
|
+
pumpPlace(placeId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return { placeLocks, inPlace };
|
|
106
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT YOUR CLAUDE IS ACTUALLY RUNNING — the processes it started, still alive.
|
|
3
|
+
*
|
|
4
|
+
* The Workbench could say what a tab had CHANGED (the diffstat) and what was
|
|
5
|
+
* LISTENING in it (`listeners.mjs`), and nothing about what it was RUNNING. A
|
|
6
|
+
* backgrounded watcher — `rbxtsc -w`, `tsc --watch`, `cargo watch` — holds no
|
|
7
|
+
* socket and touches no file for minutes at a time, so it was invisible from a
|
|
8
|
+
* browser in a way it never is in a terminal, where you can just look.
|
|
9
|
+
*
|
|
10
|
+
* ATTRIBUTED BY PROCESS GROUP, not by working directory, and that choice is the
|
|
11
|
+
* whole design. Two candidates were weighed:
|
|
12
|
+
*
|
|
13
|
+
* · BY CWD, the rule `listeners.mjs` uses. It answers "what is running in
|
|
14
|
+
* this directory", which is the wrong question here — under a shared place
|
|
15
|
+
* it sweeps in a teammate's processes and their command lines, and this
|
|
16
|
+
* product's standing rule is that a teammate's activity is never surfaced.
|
|
17
|
+
* · BY DESCENDANCY, walking ppid. It answers the right question and then
|
|
18
|
+
* loses exactly the processes worth showing: `nohup` and `setsid` reparent
|
|
19
|
+
* to init, so the long-running watcher drops off the chain the moment it
|
|
20
|
+
* becomes long-running.
|
|
21
|
+
*
|
|
22
|
+
* A process GROUP survives reparenting. The daemon spawns each turn's CLI
|
|
23
|
+
* `detached`, which makes it a group leader, and every process the agent starts
|
|
24
|
+
* inherits that pgid however it is backgrounded. So the group IS "started by
|
|
25
|
+
* the AI instructed through Flowviant", precisely, and it keeps being that
|
|
26
|
+
* after the turn ends and the CLI exits — which is when it matters.
|
|
27
|
+
*
|
|
28
|
+
* THE GROUP LEADER IS NEVER REPORTED, and this is not tidiness. The CLI is
|
|
29
|
+
* spawned as `-p <prompt> --append-system-prompt <system>`, so its own argv
|
|
30
|
+
* holds the driver's entire message and the whole operating contract. Reporting
|
|
31
|
+
* it would push several kilobytes of prompt through the wire and into a browser
|
|
32
|
+
* on every sweep. The leader is also not something the agent STARTED — it is
|
|
33
|
+
* the agent. `pid === pgid` identifies it for free.
|
|
34
|
+
*
|
|
35
|
+
* Command lines are SCRUBBED with the project's own secret values before they
|
|
36
|
+
* leave, the same treatment the activity line and the command audit get, and
|
|
37
|
+
* capped. Scrubbing catches materialized env secrets; it cannot catch a token
|
|
38
|
+
* somebody types inline, and nothing here pretends otherwise.
|
|
39
|
+
*
|
|
40
|
+
* Linux (including WSL2) reads /proc. macOS shells out to `ps`. Windows reports
|
|
41
|
+
* NOTHING and says so by returning null — the same three-state rule the rest of
|
|
42
|
+
* this product keeps, where "looked and found none" is `[]` and "cannot look"
|
|
43
|
+
* is not the same answer.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { execFileSync } from 'node:child_process';
|
|
47
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
48
|
+
import { platform } from 'node:os';
|
|
49
|
+
|
|
50
|
+
/** A box with more processes than this is not one we walk per sweep. */
|
|
51
|
+
const MAX_PIDS = 4000;
|
|
52
|
+
/** Rows reported per session. A watcher, a dev server and its child is three;
|
|
53
|
+
* twenty is somebody's compose stack and the extra rows say nothing. */
|
|
54
|
+
export const MAX_PROCS = 12;
|
|
55
|
+
/** Longest command line relayed. Long enough for `node x.js --flag value`,
|
|
56
|
+
* short enough that a pathological argv cannot become the report. */
|
|
57
|
+
const MAX_CMD = 200;
|
|
58
|
+
|
|
59
|
+
export function processesSupported() {
|
|
60
|
+
return platform() === 'linux' || platform() === 'darwin';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `pid (comm) state ppid pgrp …` — comm is arbitrary text and CAN contain a
|
|
65
|
+
* `)`, so the fields are read after the LAST one rather than by splitting the
|
|
66
|
+
* whole line. A process named `foo) bar` is not hypothetical; it is what
|
|
67
|
+
* anything that sets its own title can produce.
|
|
68
|
+
*/
|
|
69
|
+
function pgrpOf(pid) {
|
|
70
|
+
let stat;
|
|
71
|
+
try {
|
|
72
|
+
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
73
|
+
} catch {
|
|
74
|
+
return null; // gone between readdir and read — ordinary
|
|
75
|
+
}
|
|
76
|
+
const close = stat.lastIndexOf(')');
|
|
77
|
+
if (close < 0) return null;
|
|
78
|
+
const rest = stat.slice(close + 1).trim().split(/\s+/);
|
|
79
|
+
const pgrp = Number(rest[2]);
|
|
80
|
+
return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cmdlineOf(pid) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
86
|
+
const parts = raw.split('\0').filter(Boolean);
|
|
87
|
+
if (!parts.length) return null; // a kernel thread — no argv at all
|
|
88
|
+
return parts.join(' ');
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function scanLinux(pgids) {
|
|
95
|
+
let pids;
|
|
96
|
+
try {
|
|
97
|
+
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
|
|
102
|
+
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const raw of pids) {
|
|
105
|
+
const pid = Number(raw);
|
|
106
|
+
const pgrp = pgrpOf(raw);
|
|
107
|
+
if (pgrp === null || !pgids.has(pgrp)) continue;
|
|
108
|
+
// The CLI itself — its argv is the prompt. See the header.
|
|
109
|
+
if (pid === pgrp) continue;
|
|
110
|
+
const cmd = cmdlineOf(raw);
|
|
111
|
+
if (!cmd) continue;
|
|
112
|
+
out.push({ pid, pgid: pgrp, cmd });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scanDarwin(pgids) {
|
|
118
|
+
let text;
|
|
119
|
+
try {
|
|
120
|
+
text = execFileSync('ps', ['-axo', 'pid=,pgid=,command='], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
123
|
+
timeout: 5000,
|
|
124
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const line of text.split('\n')) {
|
|
131
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
132
|
+
if (!m) continue;
|
|
133
|
+
const pid = Number(m[1]);
|
|
134
|
+
const pgid = Number(m[2]);
|
|
135
|
+
if (!pgids.has(pgid) || pid === pgid) continue;
|
|
136
|
+
const cmd = m[3].trim();
|
|
137
|
+
if (cmd) out.push({ pid, pgid, cmd });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Live processes in any of `pgids`.
|
|
144
|
+
*
|
|
145
|
+
* Returns null where the platform cannot answer — never `[]`, which means
|
|
146
|
+
* "looked, found none" and is a different fact a surface renders differently.
|
|
147
|
+
*/
|
|
148
|
+
export function processesInGroups(pgids, { scrub } = {}) {
|
|
149
|
+
if (!processesSupported()) return null;
|
|
150
|
+
const want = pgids instanceof Set ? pgids : new Set(pgids ?? []);
|
|
151
|
+
if (want.size === 0) return [];
|
|
152
|
+
const rows = platform() === 'darwin' ? scanDarwin(want) : scanLinux(want);
|
|
153
|
+
// Oldest first: a pid is monotonic, so the long-running watcher you started
|
|
154
|
+
// an hour ago sorts above the `sh -c` spawned two seconds ago. Cutting from
|
|
155
|
+
// the END keeps the durable processes and drops the churn.
|
|
156
|
+
rows.sort((a, b) => a.pid - b.pid);
|
|
157
|
+
return rows.slice(0, MAX_PROCS).map((r) => ({
|
|
158
|
+
pid: r.pid,
|
|
159
|
+
cmd: String(scrub ? scrub(r.cmd) : r.cmd).slice(0, MAX_CMD),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which of `pgids` still has anything alive in it.
|
|
165
|
+
*
|
|
166
|
+
* The caller remembers a pgid per session so a backgrounded process outlives
|
|
167
|
+
* the turn that started it. That set must be pruned or it grows for the life of
|
|
168
|
+
* the daemon — and a recycled pgid would eventually attribute a stranger's
|
|
169
|
+
* process to a tab.
|
|
170
|
+
*/
|
|
171
|
+
export function liveGroups(pgids) {
|
|
172
|
+
if (!processesSupported()) return new Set(pgids ?? []);
|
|
173
|
+
const want = pgids instanceof Set ? pgids : new Set(pgids ?? []);
|
|
174
|
+
if (want.size === 0) return new Set();
|
|
175
|
+
const alive = new Set();
|
|
176
|
+
if (platform() === 'darwin') {
|
|
177
|
+
for (const r of scanDarwin(want)) alive.add(r.pgid);
|
|
178
|
+
// A group whose only member is its leader is still alive; scanDarwin drops
|
|
179
|
+
// leaders, so ask the kernel directly for the rest.
|
|
180
|
+
for (const g of want) {
|
|
181
|
+
if (alive.has(g)) continue;
|
|
182
|
+
try {
|
|
183
|
+
process.kill(g, 0);
|
|
184
|
+
alive.add(g);
|
|
185
|
+
} catch {
|
|
186
|
+
/* gone */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return alive;
|
|
190
|
+
}
|
|
191
|
+
let pids;
|
|
192
|
+
try {
|
|
193
|
+
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
194
|
+
} catch {
|
|
195
|
+
return want; // cannot tell — keep what we had rather than forget a live tab
|
|
196
|
+
}
|
|
197
|
+
for (const raw of pids) {
|
|
198
|
+
const pgrp = pgrpOf(raw);
|
|
199
|
+
if (pgrp !== null && want.has(pgrp)) alive.add(pgrp);
|
|
200
|
+
}
|
|
201
|
+
return alive;
|
|
202
|
+
}
|