@tryinget/pi-activity-strip 0.8.0 → 0.10.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.
Files changed (58) hide show
  1. package/README.md +59 -21
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +4 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +1141 -84
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +471 -0
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +5 -0
  17. package/native/panel/src/protocol.rs +93 -8
  18. package/native/panel/src/runtime.rs +85 -12
  19. package/native/panel/src/style.css +85 -0
  20. package/package.json +6 -7
  21. package/src/broker/server.mjs +31 -14
  22. package/src/client/launcher.mjs +3 -7
  23. package/src/client/runtime-lock.mjs +2 -1
  24. package/src/common/agent-identity.mjs +6 -1
  25. package/src/common/contracts.d.ts +2 -0
  26. package/src/common/contracts.ts +39 -0
  27. package/src/common/keyboard-observation.mjs +35 -0
  28. package/src/common/overlay-readiness.mjs +11 -0
  29. package/src/common/presence-apps.mjs +57 -0
  30. package/src/common/recovery-identity.mjs +85 -0
  31. package/src/common/recovery-manifest.mjs +417 -0
  32. package/src/common/recovery-types.ts +75 -0
  33. package/src/common/ribbon-status.mjs +41 -0
  34. package/src/common/status-report.mjs +37 -2
  35. package/src/common/workspace-view.mjs +118 -0
  36. package/src/native/ak-runtime.mjs +50 -9
  37. package/src/native/focus-visibility.mjs +67 -0
  38. package/src/native/ghostty-tab-inventory.py +40 -23
  39. package/src/native/hover-keyboard.mjs +32 -0
  40. package/src/native/interaction-context.mjs +45 -0
  41. package/src/native/keyboard-entry.mjs +32 -0
  42. package/src/native/main.mjs +91 -42
  43. package/src/native/panel-binary.mjs +25 -8
  44. package/src/native/panel-neutral-source.mjs +53 -0
  45. package/src/native/panel-presentation.mjs +137 -0
  46. package/src/native/panel-projection.mjs +354 -18
  47. package/src/native/panel-protocol.mjs +23 -0
  48. package/src/native/panel-window-jump.mjs +90 -0
  49. package/src/native/placement.mjs +83 -20
  50. package/src/native/recovery-inventory.mjs +209 -0
  51. package/src/native/recovery-proc.mjs +178 -0
  52. package/src/native/recovery-sources.mjs +358 -0
  53. package/src/native/runtime-diagnostics.mjs +242 -0
  54. package/src/native/strip-hotkey-kdl.mjs +252 -0
  55. package/src/native/strip-hotkey.mjs +62 -0
  56. package/src/native/tab-inventory.mjs +62 -11
  57. package/src/native/window-jump.mjs +43 -0
  58. package/src/native/workspace-events.mjs +123 -2
@@ -7,14 +7,12 @@
7
7
  import path from "node:path";
8
8
  import { ACTIVITY_STRIP_SOCKET_DIR } from "../common/constants.mjs";
9
9
  import { resolvePiSessionIdentity, resolveSessionWindow } from "../common/niri-focus.mjs";
10
- import {
11
- createSurfaceBindingStore,
12
- parseTerminalTitleBinding,
13
- } from "../common/surface-bindings.mjs";
10
+ import { createSurfaceBindingStore } from "../common/surface-bindings.mjs";
14
11
  import {
15
12
  canonicalGhosttyTerminalKey,
16
13
  resolveTerminalHostPid,
17
14
  } from "../common/terminal-identity.mjs";
15
+ import { resolveWorkspaceView } from "../common/workspace-view.mjs";
18
16
  import { mapTabInventoryToBindings, probeGhosttyTabInventory } from "./tab-inventory.mjs";
19
17
 
20
18
  // The AT-SPI tab inventory is the only exact source for tabs that have never been shown while the
@@ -59,8 +57,13 @@ export function createPlacementRuntime({
59
57
  });
60
58
  /** @type {Map<string, number>} */
61
59
  const hostPidCache = new Map();
62
- /** @type {Map<unknown, string>} */
63
- const windowSignatures = new Map();
60
+ /** Complete latest window inventory, advanced synchronously by compositor events. */
61
+ /** @type {Map<unknown, NiriWindow>} */
62
+ const observedWindows = new Map();
63
+ let windowCacheValid = false;
64
+ /** Established publisher facts from the last full projection, not title-shape inference. */
65
+ /** @type {Array<Record<string, unknown>> | null} */
66
+ let sourceSessions = null;
64
67
  let inventoryInFlight = false;
65
68
  let inventoryNextAt = 0;
66
69
 
@@ -72,10 +75,58 @@ export function createPlacementRuntime({
72
75
  lookupBinding: (bindingKey) => bindings.lookup(bindingKey),
73
76
  };
74
77
 
75
- /** Workspace plus visible surface: everything about a window that can move a card. */
76
- /** @param {NiriWindow} window */
77
- function signatureOf(window) {
78
- return `${window.workspace_id}|${parseTerminalTitleBinding(window.title)?.terminalKey ?? ""}`;
78
+ /** @param {NiriWindow[]} windows */
79
+ function cacheWindowList(windows) {
80
+ observedWindows.clear();
81
+ windowCacheValid = windows.every((window) => Number.isInteger(window?.id));
82
+ for (const window of windows) {
83
+ if (observedWindows.has(window?.id)) windowCacheValid = false;
84
+ observedWindows.set(window?.id, { ...window });
85
+ }
86
+ }
87
+
88
+ /** The same complete-window admission resolver used by full reconciliation.
89
+ * @param {Record<string, unknown>} workspace
90
+ */
91
+ function resolveCachedWorkspaceView(workspace) {
92
+ if (!windowCacheValid || sourceSessions === null) return null;
93
+ return resolveWorkspaceView([...observedWindows.values()], workspace, sourceSessions, options);
94
+ }
95
+
96
+ // Only admission changes schedule work. A Pi display prefix or an unrelated browser title
97
+ // cannot churn eligibility, but a new claimant anywhere changes the canonical projection.
98
+ function admissionSignature() {
99
+ const windows = [...observedWindows.values()];
100
+ if (!sourceSessions?.length) {
101
+ // Unknown source facts: preserve full title sensitivity, never normalize by hex shape.
102
+ return JSON.stringify(
103
+ windows.map((window) => [
104
+ window.id,
105
+ window.workspace_id,
106
+ window.app_id,
107
+ window.pid,
108
+ window.title,
109
+ ]),
110
+ );
111
+ }
112
+ const workspaceIds = [...new Set(windows.map((window) => window.workspace_id))];
113
+ const cards = workspaceIds.flatMap((id) => {
114
+ const view = resolveCachedWorkspaceView({ id });
115
+ return (view?.sessions ?? []).map((card) => {
116
+ const window = observedWindows.get(card.windowId);
117
+ return JSON.stringify([
118
+ id,
119
+ card.cardId,
120
+ card.windowId,
121
+ card.placement,
122
+ card.surfaceVisible,
123
+ card.publisherRecordKeys,
124
+ window?.app_id,
125
+ window?.pid,
126
+ ]);
127
+ });
128
+ });
129
+ return JSON.stringify([windowCacheValid, cards.sort()]);
79
130
  }
80
131
 
81
132
  /**
@@ -97,14 +148,11 @@ export function createPlacementRuntime({
97
148
 
98
149
  /** @param {NiriWindow[]} windows */
99
150
  function observeWindowList(windows) {
151
+ cacheWindowList(windows);
100
152
  if (windows.length === 0) return;
101
153
  bindings.observeWindows(windows);
102
154
  bindings.prune(windows);
103
155
  bindings.persist();
104
- windowSignatures.clear();
105
- for (const window of windows) {
106
- if (Number.isInteger(window?.id)) windowSignatures.set(window.id, signatureOf(window));
107
- }
108
156
  }
109
157
 
110
158
  /**
@@ -113,11 +161,10 @@ export function createPlacementRuntime({
113
161
  */
114
162
  function observeWindowEvent(window) {
115
163
  if (!Number.isInteger(window?.id)) return false;
164
+ const before = admissionSignature();
165
+ observedWindows.set(window.id, { ...window });
116
166
  const learned = bindings.observeWindows([window]);
117
- const signature = signatureOf(window);
118
- const changed = windowSignatures.get(window.id) !== signature;
119
- windowSignatures.set(window.id, signature);
120
- return learned.length > 0 || changed;
167
+ return learned.length > 0 || before !== admissionSignature();
121
168
  }
122
169
 
123
170
  /**
@@ -125,8 +172,11 @@ export function createPlacementRuntime({
125
172
  * same tab stays placed once it is hidden behind another tab.
126
173
  * @param {Array<Record<string, unknown>>} cards
127
174
  * @param {NiriWindow[]} windows
175
+ * @param {Array<Record<string, unknown>>} [sessions] All raw publishers, including other agents.
128
176
  */
129
- function learnFromPlacements(cards, windows) {
177
+ function learnFromPlacements(cards, windows, sessions = cards) {
178
+ sourceSessions = sessions.map((session) => ({ ...session }));
179
+ cacheWindowList(windows);
130
180
  const byId = new Map(
131
181
  windows.filter((window) => Number.isInteger(window?.id)).map((window) => [window.id, window]),
132
182
  );
@@ -149,7 +199,7 @@ export function createPlacementRuntime({
149
199
 
150
200
  /** @param {number} windowId */
151
201
  function forgetWindow(windowId) {
152
- return windowSignatures.delete(windowId);
202
+ return observedWindows.delete(windowId);
153
203
  }
154
204
 
155
205
  /** @param {NiriWindow[]} windows */
@@ -213,6 +263,19 @@ export function createPlacementRuntime({
213
263
  options,
214
264
  observeWindowList,
215
265
  observeWindowEvent,
266
+ resolveCachedWorkspaceView,
267
+ /** Read-only established fact copy; never lets an intent mutate the event cache. @param {number} id */
268
+ getObservedWindow(id) {
269
+ const window = windowCacheValid ? observedWindows.get(id) : null;
270
+ return window
271
+ ? {
272
+ id: window.id,
273
+ workspace_id: window.workspace_id,
274
+ app_id: window.app_id,
275
+ pid: window.pid,
276
+ }
277
+ : null;
278
+ },
216
279
  learnFromPlacements,
217
280
  forgetWindow,
218
281
  countUnplaced,
@@ -0,0 +1,209 @@
1
+ // ---
2
+ // summary: "Read-only native recovery inventory: procfs, metadata registries, niri queries and AK list."
3
+ // read_when: ["operating or testing the recovery manifest CLI"]
4
+ // ---
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { buildRecoveryManifest } from "../common/recovery-manifest.mjs";
11
+ import { fileIdentity, processClock, record, scanProcesses, string } from "./recovery-proc.mjs";
12
+ import {
13
+ claudeRegistry,
14
+ codexDescriptors,
15
+ inspectTranscript,
16
+ markRepublished,
17
+ nodecodeShellRoot,
18
+ nodecodeShells,
19
+ piBroker,
20
+ transcriptIndex,
21
+ } from "./recovery-sources.mjs";
22
+
23
+ /** @typedef {import('../common/recovery-types.ts').SourceCoverage} SourceCoverage */
24
+ /** @typedef {import('../common/recovery-types.ts').SessionObservation} SessionObservation */
25
+
26
+ /** Commands are fixed read-only APIs, no shell, stderr never carries source text into output.
27
+ * @param {string} executable @param {string[]} args */
28
+ function jsonCommand(executable, args) {
29
+ try {
30
+ const raw = execFileSync(executable, args, {
31
+ encoding: "utf8",
32
+ maxBuffer: 8 * 1024 * 1024,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ });
35
+ const rows = JSON.parse(raw);
36
+ if (!Array.isArray(rows)) throw new Error("not-array");
37
+ return { state: "observed", rows: rows.map(record) };
38
+ } catch {
39
+ return { state: "unavailable", rows: [] };
40
+ }
41
+ }
42
+
43
+ /** @param {SourceCoverage[]} sources @param {string} suffix */
44
+ function compositor(sources, suffix) {
45
+ const windows = jsonCommand("niri", ["msg", "-j", "windows"]);
46
+ const workspaces = jsonCommand("niri", ["msg", "-j", "workspaces"]);
47
+ sources.push(
48
+ ...[
49
+ ["windows", windows],
50
+ ["workspaces", workspaces],
51
+ ].map(([label, data]) => {
52
+ const source = /** @type {ReturnType<typeof jsonCommand>} */ (data);
53
+ return {
54
+ source: `niri-${label}-${suffix}`,
55
+ state: source.state,
56
+ observed: source.rows.length,
57
+ rejected: 0,
58
+ limitations: ["no titles retained; no compositor mutations"],
59
+ };
60
+ }),
61
+ );
62
+ return {
63
+ windows: windows.rows
64
+ .filter((r) => Number.isSafeInteger(r.id))
65
+ .map((r) => ({
66
+ id: Number(r.id),
67
+ pid: Number.isSafeInteger(r.pid) ? Number(r.pid) : null,
68
+ workspace_id: Number.isSafeInteger(r.workspace_id) ? Number(r.workspace_id) : null,
69
+ })),
70
+ workspaces: workspaces.rows
71
+ .filter((r) => Number.isSafeInteger(r.id) && Number.isSafeInteger(r.idx))
72
+ .map((r) => ({ id: Number(r.id), idx: Number(r.idx) })),
73
+ };
74
+ }
75
+
76
+ /** @param {{includePaths?: boolean; piSessionRoot?: string; home?: string; noAk?: boolean}} [options] */
77
+ export async function collectRecoveryManifest({
78
+ includePaths = false,
79
+ piSessionRoot,
80
+ home = os.homedir(),
81
+ noAk = false,
82
+ } = {}) {
83
+ const startedAt = Date.now();
84
+ const clock = processClock();
85
+ /** @type {SourceCoverage[]} */
86
+ const sources = [];
87
+ const before = scanProcesses(clock);
88
+ sources.push(before.coverage);
89
+ const niri = compositor(sources, "before");
90
+ const registryRoot = path.join(home, ".claude", "sessions");
91
+ const claude = claudeRegistry(registryRoot);
92
+ const pi = await piBroker();
93
+ const codex = codexDescriptors(
94
+ before.processes,
95
+ process.env.CODEX_HOME || path.join(home, ".codex"),
96
+ );
97
+ const shellRoot = nodecodeShellRoot(process.env);
98
+ const nodecode = nodecodeShells(shellRoot);
99
+ sources.push(claude.coverage, pi.coverage, codex.coverage, nodecode.coverage);
100
+ const claudeFiles = transcriptIndex(path.join(home, ".claude", "projects"));
101
+ const piFiles = transcriptIndex(
102
+ piSessionRoot ||
103
+ process.env.PI_CODING_AGENT_SESSION_DIR ||
104
+ path.join(process.env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent"), "sessions"),
105
+ );
106
+ sources.push(claudeFiles.coverage, piFiles.coverage);
107
+ const sessions = [...claude.sessions, ...pi.sessions, ...codex.sessions, ...nodecode.sessions];
108
+ for (const s of sessions) {
109
+ if (s.harness !== "claude" && s.harness !== "pi") continue;
110
+ const index = s.harness === "claude" ? claudeFiles : piFiles;
111
+ const files = index.files.get(s.sessionId?.toLowerCase() ?? "") ?? [];
112
+ s.transcripts = files.map((f) => inspectTranscript(f, s.sessionId ?? "", s.harness));
113
+ if (files.length === 0)
114
+ s.transcripts.push({
115
+ path: "",
116
+ state: "not-found-in-scanned-roots",
117
+ method: "bounded-filename-inventory",
118
+ });
119
+ if (files.length > 1) for (const t of s.transcripts) t.state = "ambiguous-path";
120
+ }
121
+ const ak = noAk
122
+ ? { state: "disabled", rows: [] }
123
+ : jsonCommand(process.env.PI_ACTIVITY_STRIP_AK_BIN || "ak", [
124
+ "task",
125
+ "list",
126
+ "--status",
127
+ "claimed",
128
+ "--format",
129
+ "json",
130
+ "--all",
131
+ "--verbose",
132
+ ]);
133
+ const validClaims = ak.rows.filter(
134
+ (r) => Number.isSafeInteger(r.id) && typeof r.claimed_by === "string",
135
+ );
136
+ sources.push({
137
+ source: "ak-claimed-tasks",
138
+ state: ak.state,
139
+ observed: ak.rows.length,
140
+ rejected: ak.rows.length - validClaims.length,
141
+ limitations: ["read-only current claims; no task titles, no history, no custody transfer"],
142
+ });
143
+ // Recheck source identity as well as PID birth. Ordinary heartbeat updates do not change identity.
144
+ const claudeAfter = claudeRegistry(registryRoot);
145
+ const piAfter = await piBroker();
146
+ const nodecodeAfter = nodecodeShells(shellRoot);
147
+ markRepublished(
148
+ sessions,
149
+ new Map([
150
+ ["claude-registry", claudeAfter.sessions],
151
+ ["pi-broker", piAfter.sessions],
152
+ ["nodecode-shell-presence", nodecodeAfter.sessions],
153
+ ]),
154
+ );
155
+ for (const s of sessions) {
156
+ if (s.source === "codex-open-rollout") {
157
+ try {
158
+ s.changed ||=
159
+ fs.readlinkSync(s.sourceRef) !== s.transcripts[0]?.path ||
160
+ fileIdentity(s.sourceRef).identity !== s.transcripts[0]?.identity;
161
+ } catch {
162
+ s.changed = true;
163
+ }
164
+ }
165
+ for (const t of s.transcripts) {
166
+ if (!t.identity || !t.path) continue;
167
+ try {
168
+ const now = fileIdentity(t.path);
169
+ t.changed ||= now.identity !== t.identity || now.size !== t.size;
170
+ if (now.identity !== t.identity) t.state = "file-replaced";
171
+ } catch {
172
+ t.state = "unavailable-after-capture";
173
+ t.changed = true;
174
+ }
175
+ }
176
+ }
177
+ sources.push(
178
+ { ...claudeAfter.coverage, source: `${registryRoot} (after)` },
179
+ { ...piAfter.coverage, source: "pi-activity-strip-broker-after" },
180
+ { ...nodecodeAfter.coverage, source: `${nodecodeAfter.coverage.source} (after)` },
181
+ );
182
+ const niriAfter = compositor(sources, "after");
183
+ const afterClock = processClock();
184
+ const after = scanProcesses(afterClock);
185
+ sources.push({ ...after.coverage, source: "procfs-current-user-after" });
186
+ return buildRecoveryManifest(
187
+ {
188
+ startedAt,
189
+ endedAt: Date.now(),
190
+ bootId: clock.bootId,
191
+ pidDomain: clock.pidDomain,
192
+ before: before.processes,
193
+ after: after.processes,
194
+ sessions,
195
+ sources,
196
+ windows: niri.windows,
197
+ windowsAfter: niriAfter.windows,
198
+ workspaces: niri.workspaces,
199
+ workspacesAfter: niriAfter.workspaces,
200
+ claims: validClaims.map((r) => ({
201
+ id: Number(r.id),
202
+ claimed_by: string(r.claimed_by),
203
+ status: string(r.status),
204
+ lease_expires_at: string(r.lease_expires_at),
205
+ })),
206
+ },
207
+ { includePaths },
208
+ );
209
+ }
@@ -0,0 +1,178 @@
1
+ // ---
2
+ // summary: "Bounded procfs metadata collection without emitting environment or command-line text."
3
+ // read_when: ["changing recovery process observations"]
4
+ // ---
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { classifyAgentProcess } from "../common/agent-identity.mjs";
11
+
12
+ /** @param {string} file */
13
+ export function textFile(file) {
14
+ return fs.readFileSync(file, "utf8");
15
+ }
16
+ /** @param {unknown} value */
17
+ export function string(value) {
18
+ return typeof value === "string" ? value : "";
19
+ }
20
+ /** @param {unknown} value */
21
+ export function record(value) {
22
+ return value && typeof value === "object" && !Array.isArray(value)
23
+ ? /** @type {Record<string, unknown>} */ (value)
24
+ : {};
25
+ }
26
+ /** @param {string} file */
27
+ export function fileIdentity(file) {
28
+ const s = fs.statSync(file);
29
+ return { identity: `${s.dev}:${s.ino}`, size: s.size, mtimeMs: s.mtimeMs };
30
+ }
31
+
32
+ /** @param {string} raw @param {number} pid */
33
+ export function parseProcStat(raw, pid) {
34
+ const end = raw.lastIndexOf(")");
35
+ if (!raw.startsWith(`${pid} (`) || end < 0) return null;
36
+ const fields = raw
37
+ .slice(end + 2)
38
+ .trim()
39
+ .split(/\s+/);
40
+ if (!/^\d+$/.test(fields[19] ?? "") || !/^\d+$/.test(fields[1] ?? "")) return null;
41
+ return { ppid: Number(fields[1]), startTicks: fields[19] };
42
+ }
43
+
44
+ /** @param {{comm: string; executable: string; argv0: string; argv1: string}} p */
45
+ export function recoveryHarness(p) {
46
+ const exe = path.basename(p.executable);
47
+ const first = path.basename(p.argv0);
48
+ if (
49
+ exe === "pi" ||
50
+ p.comm === "pi" ||
51
+ (exe.startsWith("node") && /\/pi-coding-agent\/(?:dist\/)?cli\.(?:js|mjs)$/.test(p.argv1))
52
+ )
53
+ return "pi";
54
+ if (exe === "angel" && p.executable.includes("/angelx-pi/")) return "angelx";
55
+ if (exe === "electron" && p.executable.includes("/agnt/")) return "agnt";
56
+ if (exe === "omp" || first === "omp") return "omp";
57
+ // Only the actual executable or runtime's script slot participates, never prompt arguments.
58
+ return (
59
+ classifyAgentProcess({
60
+ command: exe,
61
+ argv0: first,
62
+ argv1: /^(node|bun|python)/.test(exe) ? p.argv1 : "",
63
+ })?.kind ?? ""
64
+ );
65
+ }
66
+
67
+ export function processClock() {
68
+ const bootId = textFile("/proc/sys/kernel/random/boot_id").trim();
69
+ const machineId = textFile("/etc/machine-id").trim();
70
+ const namespace = fs.readlinkSync("/proc/self/ns/pid");
71
+ const ticks = Number(execFileSync("getconf", ["CLK_TCK"], { encoding: "utf8" }).trim());
72
+ const bootSeconds = Number(/^btime (\d+)$/m.exec(textFile("/proc/stat"))?.[1]);
73
+ if (!bootId || !machineId || !namespace || !(ticks > 0) || !(bootSeconds > 0))
74
+ throw new Error("process-clock-unavailable");
75
+ return {
76
+ bootId,
77
+ machineId,
78
+ pidDomain: `linux:${machineId}:${namespace}`,
79
+ ticks,
80
+ bootMs: bootSeconds * 1000,
81
+ };
82
+ }
83
+
84
+ /** @param {ReturnType<typeof processClock>} clock */
85
+ export function scanProcesses(clock) {
86
+ /** @type {import('../common/recovery-types.ts').ProcessObservation[]} */
87
+ const processes = [];
88
+ let rejected = 0;
89
+ const entries = fs.readdirSync("/proc").filter((v) => /^\d+$/.test(v));
90
+ let excluded = 0;
91
+ for (const entry of entries) {
92
+ const base = `/proc/${entry}`;
93
+ try {
94
+ if (fs.statSync(base).uid !== process.getuid?.()) {
95
+ excluded++;
96
+ continue;
97
+ }
98
+ const pid = Number(entry);
99
+ const stat = parseProcStat(textFile(`${base}/stat`), pid);
100
+ if (!stat) {
101
+ rejected++;
102
+ continue;
103
+ }
104
+ const executable = fs.readlinkSync(`${base}/exe`);
105
+ // Inspect only executable/script selectors; no arguments or environ are retained.
106
+ const argv = textFile(`${base}/cmdline`).split("\0", 2);
107
+ const comm = textFile(`${base}/comm`).trim();
108
+ const selectorDigest = createHash("sha256")
109
+ .update(
110
+ JSON.stringify([
111
+ executable,
112
+ comm,
113
+ argv[0],
114
+ /^(node|bun|python)/.test(path.basename(executable)) ? argv[1] : "",
115
+ ]),
116
+ )
117
+ .digest("hex");
118
+ const cwd = fs.readlinkSync(`${base}/cwd`);
119
+ const executableIdentity = fileIdentity(`${base}/exe`).identity;
120
+ // PID numbers read via this procfs belong to the observer's domain, not the target's ns.
121
+ const pidDomain = clock.pidDomain;
122
+ const check = parseProcStat(textFile(`${base}/stat`), pid);
123
+ if (!check || check.startTicks !== stat.startTicks || check.ppid !== stat.ppid) {
124
+ rejected++;
125
+ continue;
126
+ }
127
+ processes.push({
128
+ pid,
129
+ ...stat,
130
+ birthMs: clock.bootMs + (Number(stat.startTicks) * 1000) / clock.ticks,
131
+ bootId: clock.bootId,
132
+ pidDomain,
133
+ executable,
134
+ executableIdentity,
135
+ selectorDigest,
136
+ cwd,
137
+ harness: recoveryHarness({ comm, executable, argv0: argv[0] ?? "", argv1: argv[1] ?? "" }),
138
+ });
139
+ } catch {
140
+ rejected++;
141
+ }
142
+ }
143
+ return {
144
+ processes,
145
+ coverage: {
146
+ source: "procfs-current-user",
147
+ state: "observed",
148
+ observed: entries.length,
149
+ rejected,
150
+ limitations: [
151
+ `other-user-pids-excluded:${excluded}`,
152
+ "exec/script recognition is local evidence, not authentication",
153
+ "processes may exit or appear during scan",
154
+ ],
155
+ },
156
+ };
157
+ }
158
+
159
+ /** @param {number} pid */
160
+ export function openJsonlDescriptors(pid) {
161
+ const dir = `/proc/${pid}/fd`;
162
+ const results = [];
163
+ try {
164
+ for (const name of fs.readdirSync(dir)) {
165
+ try {
166
+ const descriptor = `${dir}/${name}`;
167
+ const target = fs.readlinkSync(descriptor);
168
+ if (target.endsWith(".jsonl"))
169
+ results.push({ descriptor, target, identity: fileIdentity(descriptor).identity });
170
+ } catch {
171
+ /* An fd can close while it is enumerated. */
172
+ }
173
+ }
174
+ } catch {
175
+ return { state: "unavailable", descriptors: [] };
176
+ }
177
+ return { state: "observed", descriptors: results };
178
+ }