@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.
- package/README.md +59 -21
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +4 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +1141 -84
- 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 +471 -0
- 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 +5 -0
- package/native/panel/src/protocol.rs +93 -8
- package/native/panel/src/runtime.rs +85 -12
- package/native/panel/src/style.css +85 -0
- package/package.json +6 -7
- 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 +2 -0
- package/src/common/contracts.ts +39 -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 +118 -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 +91 -42
- 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 +354 -18
- 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
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "Allowlisted session metadata readers; JSONL is projected only by jq, never replayed."
|
|
3
|
+
// read_when: ["changing recovery registry or transcript discovery"]
|
|
4
|
+
// ---
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { sendBrokerMessage } from "../client/broker-client.mjs";
|
|
10
|
+
import { makeMessage } from "../common/protocol.mjs";
|
|
11
|
+
import { UUID } from "../common/recovery-identity.mjs";
|
|
12
|
+
import { fileIdentity, openJsonlDescriptors, record, string, textFile } from "./recovery-proc.mjs";
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('../common/recovery-types.ts').SessionObservation} SessionObservation */
|
|
15
|
+
/** @typedef {import('../common/recovery-types.ts').SourceCoverage} SourceCoverage */
|
|
16
|
+
/** @typedef {import('../common/recovery-types.ts').TranscriptObservation} TranscriptObservation */
|
|
17
|
+
|
|
18
|
+
/** @param {string} file */
|
|
19
|
+
function metadataJson(file) {
|
|
20
|
+
const stat = fs.statSync(file);
|
|
21
|
+
if (!stat.isFile() || stat.size > 262144) throw new Error("metadata-bound");
|
|
22
|
+
return record(JSON.parse(textFile(file)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} root */
|
|
26
|
+
export function claudeRegistry(root) {
|
|
27
|
+
/** @type {SessionObservation[]} */
|
|
28
|
+
const sessions = [];
|
|
29
|
+
/** @type {SourceCoverage} */
|
|
30
|
+
const coverage = { source: root, state: "observed", observed: 0, rejected: 0, limitations: [] };
|
|
31
|
+
try {
|
|
32
|
+
const names = fs
|
|
33
|
+
.readdirSync(root)
|
|
34
|
+
.filter((v) => v.endsWith(".json"))
|
|
35
|
+
.sort();
|
|
36
|
+
coverage.observed = names.length;
|
|
37
|
+
if (names.length > 4096) coverage.limitations.push("registry-file-limit:4096");
|
|
38
|
+
for (const name of names.slice(0, 4096)) {
|
|
39
|
+
try {
|
|
40
|
+
const file = path.join(root, name);
|
|
41
|
+
const before = fileIdentity(file);
|
|
42
|
+
const row = metadataJson(file);
|
|
43
|
+
const after = fileIdentity(file);
|
|
44
|
+
const pid = row.pid;
|
|
45
|
+
const sessionId = string(row.sessionId);
|
|
46
|
+
if (!Number.isSafeInteger(pid) || Number(pid) <= 0 || !UUID.test(sessionId)) {
|
|
47
|
+
coverage.rejected++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
sessions.push({
|
|
51
|
+
harness: "claude",
|
|
52
|
+
sessionId,
|
|
53
|
+
pid: Number(pid),
|
|
54
|
+
source: "claude-registry",
|
|
55
|
+
sourceRef: file,
|
|
56
|
+
cwd: string(row.cwd),
|
|
57
|
+
startedAt: Number(row.startedAt),
|
|
58
|
+
pidDomain: string(row.pidDomain) || null,
|
|
59
|
+
procStart: string(row.procStart) || null,
|
|
60
|
+
bootId: string(row.bootId) || null,
|
|
61
|
+
changed: JSON.stringify(before) !== JSON.stringify(after),
|
|
62
|
+
transcripts: [],
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
coverage.rejected++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
coverage.state = "unavailable";
|
|
70
|
+
}
|
|
71
|
+
return { sessions, coverage };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where Nodecode's client keeps shell presence: it writes only under an absolute XDG_RUNTIME_DIR,
|
|
75
|
+
* so nothing else is read. => the directory, or null.
|
|
76
|
+
* @param {NodeJS.ProcessEnv} env */
|
|
77
|
+
export function nodecodeShellRoot(env) {
|
|
78
|
+
const runtime = env.XDG_RUNTIME_DIR ?? "";
|
|
79
|
+
return runtime.startsWith("/") ? path.join(runtime, "nodecode", "shells") : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Nodecode's own shell presence records: a terminal shell attached to its session names that
|
|
83
|
+
* session, never one it only previews, with its pid and /proc start ticks, and removes the record
|
|
84
|
+
* when it ends. Detached sessions and RPC clients write none. A record has no PID-namespace domain
|
|
85
|
+
* or boot id. Its resume command, home and profile are never read.
|
|
86
|
+
* @param {string | null} root */
|
|
87
|
+
export function nodecodeShells(root) {
|
|
88
|
+
/** @type {SessionObservation[]} */
|
|
89
|
+
const sessions = [];
|
|
90
|
+
/** @type {SourceCoverage} */
|
|
91
|
+
const coverage = {
|
|
92
|
+
source: root ?? "nodecode-shell-presence",
|
|
93
|
+
state: "observed",
|
|
94
|
+
observed: 0,
|
|
95
|
+
rejected: 0,
|
|
96
|
+
limitations: [
|
|
97
|
+
"attached terminal shells only; no PID-namespace domain or boot id; ids unique per Nodecode home",
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
if (root === null) {
|
|
101
|
+
coverage.state = "unavailable";
|
|
102
|
+
coverage.limitations.push("XDG_RUNTIME_DIR unset or relative");
|
|
103
|
+
return { sessions, coverage };
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const names = fs
|
|
107
|
+
.readdirSync(root)
|
|
108
|
+
.filter((v) => v.endsWith(".json"))
|
|
109
|
+
.sort();
|
|
110
|
+
coverage.observed = names.length;
|
|
111
|
+
if (names.length > 4096) coverage.limitations.push("registry-file-limit:4096");
|
|
112
|
+
for (const name of names.slice(0, 4096)) {
|
|
113
|
+
try {
|
|
114
|
+
const file = path.join(root, name);
|
|
115
|
+
// The writer replaces a regular file atomically; it never links one in.
|
|
116
|
+
if (!fs.lstatSync(file).isFile()) throw new Error("not-a-regular-file");
|
|
117
|
+
const before = fileIdentity(file);
|
|
118
|
+
const row = metadataJson(file);
|
|
119
|
+
const after = fileIdentity(file);
|
|
120
|
+
const { pid, start_ticks: ticks } = row;
|
|
121
|
+
const sessionId = string(row.session_id);
|
|
122
|
+
// Without start ticks a record could claim a later Nodecode on its pid, so it is refused.
|
|
123
|
+
if (
|
|
124
|
+
row.version !== 1 ||
|
|
125
|
+
row.kind !== "nodecode-shell" ||
|
|
126
|
+
!Number.isSafeInteger(pid) ||
|
|
127
|
+
Number(pid) <= 0 ||
|
|
128
|
+
name !== `${pid}.json` ||
|
|
129
|
+
!Number.isSafeInteger(ticks) ||
|
|
130
|
+
Number(ticks) < 0 ||
|
|
131
|
+
!/^[!-~]{1,128}$/.test(sessionId)
|
|
132
|
+
) {
|
|
133
|
+
coverage.rejected++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
sessions.push({
|
|
137
|
+
harness: "nodecode",
|
|
138
|
+
sessionId,
|
|
139
|
+
pid: Number(pid),
|
|
140
|
+
source: "nodecode-shell-presence",
|
|
141
|
+
sourceRef: file,
|
|
142
|
+
cwd: string(row.cwd),
|
|
143
|
+
startedAt: 0,
|
|
144
|
+
pidDomain: null,
|
|
145
|
+
procStart: String(ticks),
|
|
146
|
+
bootId: null,
|
|
147
|
+
changed: JSON.stringify(before) !== JSON.stringify(after),
|
|
148
|
+
transcripts: [],
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
coverage.rejected++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
coverage.state = "unavailable";
|
|
156
|
+
}
|
|
157
|
+
return { sessions, coverage };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Mark each session whose source, read again after the capture, no longer says the same thing:
|
|
161
|
+
* same pid, session, start, cwd, birth token and domain. Ordinary heartbeats do not count.
|
|
162
|
+
* @param {SessionObservation[]} sessions @param {Map<string, SessionObservation[]>} after */
|
|
163
|
+
export function markRepublished(sessions, after) {
|
|
164
|
+
for (const s of sessions) {
|
|
165
|
+
const peers = after.get(s.source);
|
|
166
|
+
if (!peers) continue;
|
|
167
|
+
s.changed ||= !peers.some(
|
|
168
|
+
(a) =>
|
|
169
|
+
a.pid === s.pid &&
|
|
170
|
+
a.sessionId === s.sessionId &&
|
|
171
|
+
a.startedAt === s.startedAt &&
|
|
172
|
+
a.cwd === s.cwd &&
|
|
173
|
+
a.procStart === s.procStart &&
|
|
174
|
+
a.pidDomain === s.pidDomain &&
|
|
175
|
+
!a.changed,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function piBroker() {
|
|
181
|
+
/** @type {SessionObservation[]} */
|
|
182
|
+
const sessions = [];
|
|
183
|
+
/** @type {SourceCoverage} */
|
|
184
|
+
const coverage = {
|
|
185
|
+
source: "pi-activity-strip-broker",
|
|
186
|
+
state: "observed",
|
|
187
|
+
observed: 0,
|
|
188
|
+
rejected: 0,
|
|
189
|
+
limitations: ["only broker publishers, not all Pi sessions; no source process-birth token"],
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const response = await sendBrokerMessage(makeMessage("ping"), { expectReply: true });
|
|
193
|
+
if (!response?.ok || !Array.isArray(response.snapshot?.sessions))
|
|
194
|
+
throw new Error("broker-unavailable");
|
|
195
|
+
const rows = response.snapshot.sessions;
|
|
196
|
+
coverage.observed = rows.length;
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (!row.sessionId || !Number.isSafeInteger(row.processId)) {
|
|
199
|
+
coverage.rejected++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
sessions.push({
|
|
203
|
+
harness: "pi",
|
|
204
|
+
sessionId: row.sessionId,
|
|
205
|
+
pid: row.processId,
|
|
206
|
+
source: "pi-broker",
|
|
207
|
+
sourceRef: "pi-activity-strip broker ping",
|
|
208
|
+
cwd: row.cwd,
|
|
209
|
+
startedAt: row.startedAt,
|
|
210
|
+
updatedAt: row.updatedAt,
|
|
211
|
+
pidDomain: null,
|
|
212
|
+
procStart: null,
|
|
213
|
+
changed: false,
|
|
214
|
+
transcripts: [],
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
coverage.state = "unavailable";
|
|
219
|
+
}
|
|
220
|
+
return { sessions, coverage };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Scan exactly root plus one directory level, filenames only; no symlink directories.
|
|
224
|
+
* @param {string} root */
|
|
225
|
+
export function transcriptIndex(root) {
|
|
226
|
+
/** @type {Map<string, string[]>} */
|
|
227
|
+
const files = new Map();
|
|
228
|
+
/** @type {SourceCoverage} */
|
|
229
|
+
const coverage = {
|
|
230
|
+
source: root,
|
|
231
|
+
state: "observed",
|
|
232
|
+
observed: 0,
|
|
233
|
+
rejected: 0,
|
|
234
|
+
limitations: ["filenames only; depth <= 2; limit 100000; no historical-session inventory"],
|
|
235
|
+
};
|
|
236
|
+
let examined = 0;
|
|
237
|
+
/** @param {string} dir @param {number} depth */
|
|
238
|
+
function visit(dir, depth) {
|
|
239
|
+
try {
|
|
240
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
241
|
+
if (++examined > 100000) {
|
|
242
|
+
coverage.state = "truncated";
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const file = path.join(dir, entry.name);
|
|
246
|
+
if (entry.isDirectory() && depth === 0) visit(file, 1);
|
|
247
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
248
|
+
coverage.observed++;
|
|
249
|
+
const match = /(?:^|_)([0-9a-f-]{36})\.jsonl$/i.exec(entry.name);
|
|
250
|
+
if (!match || !UUID.test(match[1])) continue;
|
|
251
|
+
const id = match[1].toLowerCase();
|
|
252
|
+
files.set(id, [...(files.get(id) ?? []), file]);
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
coverage.rejected++;
|
|
256
|
+
coverage.state = "partial-or-unavailable";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
visit(root, 0);
|
|
260
|
+
return { files, coverage };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** First record only, projected through jq. No raw session text enters JS or diagnostic errors.
|
|
264
|
+
* @param {string} file @param {string} harness */
|
|
265
|
+
export function sessionHeader(file, harness) {
|
|
266
|
+
const filter =
|
|
267
|
+
harness === "pi"
|
|
268
|
+
? 'if .type == "session" then {id, cwd, timestamp} else {} end, halt'
|
|
269
|
+
: 'if .type == "session_meta" then .payload | {id, cwd, timestamp} else {} end, halt';
|
|
270
|
+
try {
|
|
271
|
+
const raw = execFileSync("jq", ["-c", filter, "--", file], {
|
|
272
|
+
encoding: "utf8",
|
|
273
|
+
maxBuffer: 65536,
|
|
274
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
275
|
+
});
|
|
276
|
+
return record(JSON.parse(raw));
|
|
277
|
+
} catch {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string} file @param {string} id @param {string} harness @returns {TranscriptObservation} */
|
|
283
|
+
export function inspectTranscript(file, id, harness) {
|
|
284
|
+
try {
|
|
285
|
+
const before = fileIdentity(file);
|
|
286
|
+
const header = harness === "claude" ? null : sessionHeader(file, harness);
|
|
287
|
+
const after = fileIdentity(file);
|
|
288
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
289
|
+
return {
|
|
290
|
+
path: file,
|
|
291
|
+
state:
|
|
292
|
+
header === null
|
|
293
|
+
? "filename-candidate"
|
|
294
|
+
: string(header.id) === id
|
|
295
|
+
? "header-id-matched"
|
|
296
|
+
: "header-unconfirmed",
|
|
297
|
+
method: header === null ? "filename-and-stat-only; content-not-read" : "jq-first-record-id",
|
|
298
|
+
...after,
|
|
299
|
+
changed,
|
|
300
|
+
};
|
|
301
|
+
} catch {
|
|
302
|
+
return { path: file, state: "unavailable", method: "stat" };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {import('../common/recovery-types.ts').ProcessObservation[]} processes @param {string} codexHome */
|
|
307
|
+
export function codexDescriptors(processes, codexHome) {
|
|
308
|
+
/** @type {SessionObservation[]} */
|
|
309
|
+
const sessions = [];
|
|
310
|
+
/** @type {SourceCoverage} */
|
|
311
|
+
const coverage = {
|
|
312
|
+
source: "codex-open-rollout-descriptors",
|
|
313
|
+
state: "observed",
|
|
314
|
+
observed: 0,
|
|
315
|
+
rejected: 0,
|
|
316
|
+
limitations: ["no SQLite reads or recent-cwd fallback; current display unsupported"],
|
|
317
|
+
};
|
|
318
|
+
for (const p of processes.filter((p) => p.harness === "codex")) {
|
|
319
|
+
const fds = openJsonlDescriptors(p.pid);
|
|
320
|
+
if (fds.state !== "observed") coverage.rejected++;
|
|
321
|
+
const seen = new Set();
|
|
322
|
+
for (const fd of fds.descriptors) {
|
|
323
|
+
if (!fd.target.startsWith(`${codexHome}/sessions/`) || seen.has(fd.target)) continue;
|
|
324
|
+
seen.add(fd.target);
|
|
325
|
+
coverage.observed++;
|
|
326
|
+
const header = sessionHeader(fd.target, "codex");
|
|
327
|
+
const id = string(header.id);
|
|
328
|
+
if (!UUID.test(id)) {
|
|
329
|
+
coverage.rejected++;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
let changed = true;
|
|
333
|
+
try {
|
|
334
|
+
changed =
|
|
335
|
+
fs.readlinkSync(fd.descriptor) !== fd.target ||
|
|
336
|
+
fileIdentity(fd.descriptor).identity !== fd.identity ||
|
|
337
|
+
fileIdentity(fd.target).identity !== fd.identity;
|
|
338
|
+
} catch {
|
|
339
|
+
/* retain changed */
|
|
340
|
+
}
|
|
341
|
+
sessions.push({
|
|
342
|
+
harness: "codex",
|
|
343
|
+
sessionId: id,
|
|
344
|
+
pid: p.pid,
|
|
345
|
+
source: "codex-open-rollout",
|
|
346
|
+
sourceRef: fd.descriptor,
|
|
347
|
+
cwd: string(header.cwd),
|
|
348
|
+
startedAt: Date.parse(string(header.timestamp)),
|
|
349
|
+
pidDomain: p.pidDomain,
|
|
350
|
+
procStart: p.startTicks,
|
|
351
|
+
bootId: p.bootId,
|
|
352
|
+
changed,
|
|
353
|
+
transcripts: [inspectTranscript(fd.target, id, "codex")],
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { sessions, coverage };
|
|
358
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "read-only, allowlisted ribbon diagnostics; observations never grant input or AK authority"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing ribbon build/focus/ownership explanations or their privacy boundary"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import { open, readlink, stat } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { normalizeKeyboardObservation } from "../common/keyboard-observation.mjs";
|
|
13
|
+
import { PANEL_PROTOCOL } from "./panel-protocol.mjs";
|
|
14
|
+
import { parseProcStat } from "./recovery-proc.mjs";
|
|
15
|
+
|
|
16
|
+
/** Record only the current supervised child's optional native-clock observation.
|
|
17
|
+
* This never calls projection/admission and refuses earlier requests or backwards native clocks.
|
|
18
|
+
* @param {import("../common/contracts.ts").ActivityStripRuntimeStatus} status
|
|
19
|
+
* @param {unknown} event @param {number | undefined} panelPid */
|
|
20
|
+
export function recordNativeKeyboardObservation(status, event, panelPid) {
|
|
21
|
+
const observation = normalizeKeyboardObservation(event);
|
|
22
|
+
if (
|
|
23
|
+
!observation ||
|
|
24
|
+
!Number.isSafeInteger(panelPid) ||
|
|
25
|
+
Number(panelPid) <= 0 ||
|
|
26
|
+
panelPid !== status.panelPid
|
|
27
|
+
)
|
|
28
|
+
return false;
|
|
29
|
+
const previous = status.nativeKeyboardObservation;
|
|
30
|
+
if (
|
|
31
|
+
previous &&
|
|
32
|
+
previous.panelPid === panelPid &&
|
|
33
|
+
(observation.request < previous.request || observation.nativeMs < previous.nativeMs)
|
|
34
|
+
)
|
|
35
|
+
return false;
|
|
36
|
+
status.nativeKeyboardObservation = { ...observation, panelPid: Number(panelPid) };
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {import("../common/contracts.ts").ActivityStripRuntimeStatus} status */
|
|
41
|
+
function currentNativeObservation(status) {
|
|
42
|
+
const previous = status.nativeKeyboardObservation;
|
|
43
|
+
if (status.state !== "ready" || !previous || previous.panelPid !== status.panelPid) return null;
|
|
44
|
+
const observation = normalizeKeyboardObservation({
|
|
45
|
+
...previous,
|
|
46
|
+
protocol: 2,
|
|
47
|
+
type: "keyboard-observation",
|
|
48
|
+
});
|
|
49
|
+
return observation ? { ...observation, panelPid: previous.panelPid } : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
53
|
+
/** @param {import("node:crypto").BinaryLike} bytes */
|
|
54
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
55
|
+
/** @param {unknown} value */
|
|
56
|
+
const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
57
|
+
|
|
58
|
+
// Startup source-tree observation, NOT proof of all in-memory bytes or AK custody.
|
|
59
|
+
function startupSourceSha256() {
|
|
60
|
+
try {
|
|
61
|
+
const files = ["package.json"];
|
|
62
|
+
/** @param {string} relative */
|
|
63
|
+
const visit = (relative) => {
|
|
64
|
+
for (const entry of fs.readdirSync(path.join(packageRoot, relative), {
|
|
65
|
+
withFileTypes: true,
|
|
66
|
+
})) {
|
|
67
|
+
const next = path.join(relative, entry.name);
|
|
68
|
+
if (entry.isDirectory()) visit(next);
|
|
69
|
+
else if (entry.isFile()) files.push(next);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
visit("src");
|
|
73
|
+
return sha256(
|
|
74
|
+
files
|
|
75
|
+
.sort()
|
|
76
|
+
.map((file) => `${file}\0${sha256(fs.readFileSync(path.join(packageRoot, file)))}\n`)
|
|
77
|
+
.join(""),
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Descriptor-bound, capped, asynchronous read; FIFOs/devices cannot block the controller loop.
|
|
85
|
+
* @param {string} file @param {number} cap @param {boolean} [followSymlink] */
|
|
86
|
+
async function boundedFile(file, cap, followSymlink = false) {
|
|
87
|
+
const handle = await open(
|
|
88
|
+
file,
|
|
89
|
+
fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (followSymlink ? 0 : fs.constants.O_NOFOLLOW),
|
|
90
|
+
);
|
|
91
|
+
try {
|
|
92
|
+
const before = await handle.stat();
|
|
93
|
+
if (!before.isFile() || before.size > cap) throw new Error("unavailable");
|
|
94
|
+
const bytes = Buffer.alloc(cap + 1);
|
|
95
|
+
let length = 0;
|
|
96
|
+
while (length <= cap) {
|
|
97
|
+
const { bytesRead } = await handle.read(bytes, length, cap + 1 - length, length);
|
|
98
|
+
if (!bytesRead) break;
|
|
99
|
+
length += bytesRead;
|
|
100
|
+
}
|
|
101
|
+
const after = await handle.stat();
|
|
102
|
+
if (length > cap || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
103
|
+
throw new Error("changed");
|
|
104
|
+
return { bytes: bytes.subarray(0, length), identity: `${after.dev}:${after.ino}` };
|
|
105
|
+
} finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Non-atomic PID observation, checked for birth/executable changes during the sample.
|
|
111
|
+
* Receipt matching means integrity, NOT approval or original-child custody.
|
|
112
|
+
* @param {number | null | undefined} pid @param {{procRoot?: string}} [options] */
|
|
113
|
+
export async function inspectPanelBuild(pid, { procRoot = "/proc" } = {}) {
|
|
114
|
+
if (!Number.isSafeInteger(pid) || Number(pid) <= 0) return { state: "unavailable" };
|
|
115
|
+
const base = path.join(procRoot, String(pid));
|
|
116
|
+
let binarySha256;
|
|
117
|
+
try {
|
|
118
|
+
const birth = parseProcStat(
|
|
119
|
+
(await boundedFile(path.join(base, "stat"), 8192)).bytes.toString(),
|
|
120
|
+
Number(pid),
|
|
121
|
+
);
|
|
122
|
+
if (!birth) return { state: "unavailable" };
|
|
123
|
+
const exe = path.join(base, "exe");
|
|
124
|
+
const location = await readlink(exe);
|
|
125
|
+
const executable = await boundedFile(exe, 16 * 1024 * 1024, true);
|
|
126
|
+
binarySha256 = sha256(executable.bytes);
|
|
127
|
+
let receipt;
|
|
128
|
+
try {
|
|
129
|
+
receipt = JSON.parse(
|
|
130
|
+
(
|
|
131
|
+
await boundedFile(path.join(path.dirname(location), "artifact.json"), 64 * 1024)
|
|
132
|
+
).bytes.toString(),
|
|
133
|
+
);
|
|
134
|
+
} catch {
|
|
135
|
+
receipt = null;
|
|
136
|
+
}
|
|
137
|
+
const afterBirth = parseProcStat(
|
|
138
|
+
(await boundedFile(path.join(base, "stat"), 8192)).bytes.toString(),
|
|
139
|
+
Number(pid),
|
|
140
|
+
);
|
|
141
|
+
const afterExe = await stat(exe);
|
|
142
|
+
if (
|
|
143
|
+
birth.startTicks !== afterBirth?.startTicks ||
|
|
144
|
+
(await readlink(exe)) !== location ||
|
|
145
|
+
executable.identity !== `${afterExe.dev}:${afterExe.ino}`
|
|
146
|
+
)
|
|
147
|
+
return { state: "changed-during-observation" };
|
|
148
|
+
if (!receipt)
|
|
149
|
+
return { state: "unavailable", binarySha256, processStartTicks: birth.startTicks };
|
|
150
|
+
const privateProtocol = Number.isSafeInteger(receipt.panelProtocol)
|
|
151
|
+
? receipt.panelProtocol
|
|
152
|
+
: null;
|
|
153
|
+
return {
|
|
154
|
+
state:
|
|
155
|
+
privateProtocol !== PANEL_PROTOCOL
|
|
156
|
+
? "incompatible"
|
|
157
|
+
: receipt.schema === "pi-activity-strip-native-artifact.v1" &&
|
|
158
|
+
receipt.target === "x86_64-unknown-linux-gnu" &&
|
|
159
|
+
receipt.sha256 === binarySha256
|
|
160
|
+
? "receipt-matched"
|
|
161
|
+
: "receipt-mismatch",
|
|
162
|
+
binarySha256,
|
|
163
|
+
processStartTicks: birth.startTicks,
|
|
164
|
+
privateProtocol,
|
|
165
|
+
receiptedSourceSha256: isHash(receipt.sourceSha256) ? receipt.sourceSha256 : null,
|
|
166
|
+
};
|
|
167
|
+
} catch {
|
|
168
|
+
// A failed generation check must not retain a digest attributed to the wrong process.
|
|
169
|
+
return { state: "unavailable" };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {{runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
|
|
174
|
+
* projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>}} options */
|
|
175
|
+
export function createRuntimeStatusReader({ runtimeStatus, projection }) {
|
|
176
|
+
const controllerStartupSourceSha256 = startupSourceSha256();
|
|
177
|
+
/** @type {Awaited<ReturnType<typeof inspectPanelBuild>>} */
|
|
178
|
+
let panelBuild = { state: "unavailable" };
|
|
179
|
+
let observedPid = runtimeStatus.panelPid;
|
|
180
|
+
let sampledAt = 0;
|
|
181
|
+
/** @type {Promise<void> | null} */
|
|
182
|
+
let pending = null;
|
|
183
|
+
function refresh() {
|
|
184
|
+
const pid = runtimeStatus.panelPid;
|
|
185
|
+
if (pid !== observedPid) {
|
|
186
|
+
observedPid = pid;
|
|
187
|
+
sampledAt = 0;
|
|
188
|
+
panelBuild = { state: "pending" };
|
|
189
|
+
}
|
|
190
|
+
if (!pending && (sampledAt === 0 || Date.now() - sampledAt >= 1000)) {
|
|
191
|
+
pending = inspectPanelBuild(pid)
|
|
192
|
+
.then((next) => {
|
|
193
|
+
if (runtimeStatus.panelPid === pid) {
|
|
194
|
+
panelBuild = next;
|
|
195
|
+
sampledAt = Date.now();
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
.finally(() => {
|
|
199
|
+
pending = null;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return () => {
|
|
204
|
+
refresh(); // async sampling only; status/keyboard event handling never waits for file I/O.
|
|
205
|
+
const facts = projection.diagnostics();
|
|
206
|
+
const nativeKeyboardObservation = currentNativeObservation(runtimeStatus);
|
|
207
|
+
const visibilityReason =
|
|
208
|
+
runtimeStatus.state !== "ready"
|
|
209
|
+
? runtimeStatus.state
|
|
210
|
+
: facts.persistentPresentation
|
|
211
|
+
? runtimeStatus.windowVisible === true
|
|
212
|
+
? "persistent-workspace"
|
|
213
|
+
: "awaiting-panel-visibility"
|
|
214
|
+
: !facts.contextValid
|
|
215
|
+
? "unproven-focus"
|
|
216
|
+
: facts.focus === "non-agent"
|
|
217
|
+
? "non-agent-focus"
|
|
218
|
+
: !facts.eligible
|
|
219
|
+
? "no-admitted-owner"
|
|
220
|
+
: runtimeStatus.windowVisible !== true
|
|
221
|
+
? "awaiting-panel-visibility"
|
|
222
|
+
: facts.focus === "neutral" || facts.source === "neutral"
|
|
223
|
+
? "eligible-presence-context"
|
|
224
|
+
: "eligible-agent-context";
|
|
225
|
+
return {
|
|
226
|
+
...runtimeStatus,
|
|
227
|
+
nativeKeyboardObservation,
|
|
228
|
+
warnings: [...(runtimeStatus.warnings ?? [])],
|
|
229
|
+
ribbonDiagnostics: {
|
|
230
|
+
controllerStartupSourceSha256,
|
|
231
|
+
expectedPrivateProtocol: PANEL_PROTOCOL,
|
|
232
|
+
panelBuild: { ...panelBuild },
|
|
233
|
+
panelBuildObservedAt: sampledAt || null,
|
|
234
|
+
visibilityReason,
|
|
235
|
+
...facts,
|
|
236
|
+
// Last observed native-domain fact only: never infer expiry from the controller clock.
|
|
237
|
+
captureExpiry: nativeKeyboardObservation ? "native-observed" : "not-observed",
|
|
238
|
+
nativeKeyboardObservation,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
}
|