@tryinget/pi-activity-strip 0.9.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.
- package/README.md +59 -22
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +3 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +572 -266
- package/native/panel/src/app_gui_tests.rs +730 -0
- package/native/panel/src/app_hover_gui_tests.rs +917 -0
- package/native/panel/src/card_view.rs +19 -4
- package/native/panel/src/directory_view.rs +79 -17
- package/native/panel/src/hints.rs +65 -0
- package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
- package/native/panel/src/hover_keyboard.rs +237 -0
- package/native/panel/src/interaction.rs +156 -0
- package/native/panel/src/keyboard-observation-fixtures.json +241 -0
- package/native/panel/src/keyboard_observation.rs +70 -0
- package/native/panel/src/main.rs +4 -0
- package/native/panel/src/protocol.rs +22 -8
- package/native/panel/src/runtime.rs +43 -2
- package/package.json +5 -5
- package/src/broker/server.mjs +31 -14
- package/src/client/launcher.mjs +3 -7
- package/src/client/runtime-lock.mjs +2 -1
- package/src/common/agent-identity.mjs +6 -1
- package/src/common/contracts.d.ts +1 -0
- package/src/common/contracts.ts +38 -0
- package/src/common/keyboard-observation.mjs +35 -0
- package/src/common/overlay-readiness.mjs +11 -0
- package/src/common/presence-apps.mjs +57 -0
- package/src/common/recovery-identity.mjs +85 -0
- package/src/common/recovery-manifest.mjs +417 -0
- package/src/common/recovery-types.ts +75 -0
- package/src/common/ribbon-status.mjs +41 -0
- package/src/common/status-report.mjs +37 -2
- package/src/common/workspace-view.mjs +54 -0
- package/src/native/ak-runtime.mjs +50 -9
- package/src/native/focus-visibility.mjs +67 -0
- package/src/native/ghostty-tab-inventory.py +40 -23
- package/src/native/hover-keyboard.mjs +32 -0
- package/src/native/interaction-context.mjs +45 -0
- package/src/native/keyboard-entry.mjs +32 -0
- package/src/native/main.mjs +77 -71
- package/src/native/panel-binary.mjs +25 -8
- package/src/native/panel-neutral-source.mjs +53 -0
- package/src/native/panel-presentation.mjs +137 -0
- package/src/native/panel-projection.mjs +338 -24
- package/src/native/panel-protocol.mjs +23 -0
- package/src/native/panel-window-jump.mjs +90 -0
- package/src/native/placement.mjs +83 -20
- package/src/native/recovery-inventory.mjs +209 -0
- package/src/native/recovery-proc.mjs +178 -0
- package/src/native/recovery-sources.mjs +358 -0
- package/src/native/runtime-diagnostics.mjs +242 -0
- package/src/native/strip-hotkey-kdl.mjs +252 -0
- package/src/native/strip-hotkey.mjs +62 -0
- package/src/native/tab-inventory.mjs +62 -11
- package/src/native/window-jump.mjs +43 -0
- package/src/native/workspace-events.mjs +123 -2
package/src/native/placement.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
63
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|