@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
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "Pure, conservative recovery links with observation coverage, never custody transfer."
|
|
3
|
+
// read_when: ["changing recovery manifest proof semantics"]
|
|
4
|
+
// ---
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { claimantFor, parseClaimant } from "./recovery-identity.mjs";
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./recovery-types.ts').ProcessObservation} ProcessObservation */
|
|
9
|
+
/** @typedef {import('./recovery-types.ts').SessionObservation} SessionObservation */
|
|
10
|
+
/** @typedef {import('./recovery-types.ts').RecoveryInputs} RecoveryInputs */
|
|
11
|
+
|
|
12
|
+
export const MANIFEST_SCHEMA = "pi-activity-strip.recovery-manifest.v1";
|
|
13
|
+
// /proc/stat btime has one-second precision. This is a comparison allowance, not identity proof.
|
|
14
|
+
export const BIRTH_CLOCK_ALLOWANCE_MS = 2000;
|
|
15
|
+
|
|
16
|
+
/** Preserve unmatched/reused live processes independently of stale registry assertions.
|
|
17
|
+
* @param {RecoveryInputs} input @returns {SessionObservation[]} */
|
|
18
|
+
export function withProcessOnlyObservations(input) {
|
|
19
|
+
const result = [...input.sessions];
|
|
20
|
+
const linked = new Set(
|
|
21
|
+
input.sessions.map((s) => processLink(s, input).process?.pid).filter(Boolean),
|
|
22
|
+
);
|
|
23
|
+
for (const p of input.before.filter((p) => p.harness)) {
|
|
24
|
+
if (linked.has(p.pid)) continue;
|
|
25
|
+
result.push({
|
|
26
|
+
harness: p.harness,
|
|
27
|
+
sessionId: null,
|
|
28
|
+
pid: p.pid,
|
|
29
|
+
source: "process-scan",
|
|
30
|
+
sourceRef: `/proc/${p.pid}/stat`,
|
|
31
|
+
cwd: p.cwd,
|
|
32
|
+
startedAt: p.birthMs,
|
|
33
|
+
pidDomain: p.pidDomain,
|
|
34
|
+
procStart: p.startTicks,
|
|
35
|
+
bootId: p.bootId,
|
|
36
|
+
changed: false,
|
|
37
|
+
transcripts: [],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const RECOVERY_CAPABILITIES = [
|
|
44
|
+
{
|
|
45
|
+
harness: "pi",
|
|
46
|
+
session: "broker-self-report",
|
|
47
|
+
transcript: "header-id",
|
|
48
|
+
currentDisplay: "unproved",
|
|
49
|
+
claimantPrefix: "session-",
|
|
50
|
+
limitation: "broker has no process-birth token; custom session roots need --pi-session-root",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
harness: "claude",
|
|
54
|
+
session: "registry-pid-domain-procStart",
|
|
55
|
+
transcript: "filename-candidate",
|
|
56
|
+
currentDisplay: "unproved",
|
|
57
|
+
claimantPrefix: "claude-session-",
|
|
58
|
+
limitation: "registry lacks boot id; start-time mismatch retained; transcript content not read",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
harness: "codex",
|
|
62
|
+
session: "open-rollout-header",
|
|
63
|
+
transcript: "open-descriptor",
|
|
64
|
+
currentDisplay: "unsupported",
|
|
65
|
+
claimantPrefix: "codex-session-",
|
|
66
|
+
limitation:
|
|
67
|
+
"open descriptor proves neither writer nor currently displayed thread; no cwd/recency fallback",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
harness: "nodecode",
|
|
71
|
+
session: "shell-presence-pid-startTicks",
|
|
72
|
+
transcript: "unsupported",
|
|
73
|
+
currentDisplay: "unproved",
|
|
74
|
+
claimantPrefix: "nodecode-session-",
|
|
75
|
+
limitation:
|
|
76
|
+
"presence record lacks pid domain and boot id, so links stay unconfirmed; ids unique per home",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
harness: "angelx",
|
|
80
|
+
session: "unsupported",
|
|
81
|
+
transcript: "unsupported",
|
|
82
|
+
currentDisplay: "unsupported",
|
|
83
|
+
claimantPrefix: null,
|
|
84
|
+
limitation:
|
|
85
|
+
"installed saved-session metadata has no verified live presence; filename PID may be historical",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
harness: "agnt",
|
|
89
|
+
session: "unsupported",
|
|
90
|
+
transcript: "unsupported",
|
|
91
|
+
currentDisplay: "unsupported",
|
|
92
|
+
claimantPrefix: null,
|
|
93
|
+
limitation:
|
|
94
|
+
"Electron application; frontend activeConversationId has no verified process-birth publisher",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
harness: "omp",
|
|
98
|
+
session: "unsupported",
|
|
99
|
+
transcript: "unsupported",
|
|
100
|
+
currentDisplay: "unsupported",
|
|
101
|
+
claimantPrefix: null,
|
|
102
|
+
limitation:
|
|
103
|
+
"no qualified current-session adapter; breadcrumbs and daemon client IDs are not live conversation identity",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
harness: "unknown",
|
|
107
|
+
session: "unsupported",
|
|
108
|
+
transcript: "unsupported",
|
|
109
|
+
currentDisplay: "unsupported",
|
|
110
|
+
claimantPrefix: null,
|
|
111
|
+
limitation: "no guessed UUID, command replay, or canonical AK prefix",
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** @param {ProcessObservation | undefined} a @param {ProcessObservation | undefined} b */
|
|
116
|
+
export function sameProcess(a, b) {
|
|
117
|
+
return Boolean(
|
|
118
|
+
a &&
|
|
119
|
+
b &&
|
|
120
|
+
a.pid === b.pid &&
|
|
121
|
+
a.startTicks === b.startTicks &&
|
|
122
|
+
a.bootId === b.bootId &&
|
|
123
|
+
a.pidDomain === b.pidDomain &&
|
|
124
|
+
a.executableIdentity === b.executableIdentity &&
|
|
125
|
+
a.ppid === b.ppid &&
|
|
126
|
+
a.harness === b.harness &&
|
|
127
|
+
a.selectorDigest === b.selectorDigest,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Namespace is checked BEFORE looking up a host PID, including coincident start ticks.
|
|
132
|
+
* @param {SessionObservation} session @param {RecoveryInputs} input */
|
|
133
|
+
export function processLink(session, input) {
|
|
134
|
+
const provenance = [session.sourceRef];
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const uncertainties = [];
|
|
137
|
+
const result = (
|
|
138
|
+
state = "unresolved",
|
|
139
|
+
process = /** @type {ProcessObservation | null} */ (null),
|
|
140
|
+
) => ({ state, process, provenance, uncertainties });
|
|
141
|
+
if (session.pidDomain && session.pidDomain !== input.pidDomain) {
|
|
142
|
+
uncertainties.push("foreign-pid-domain-no-mapping");
|
|
143
|
+
return result("foreign-namespace");
|
|
144
|
+
}
|
|
145
|
+
if (session.bootId && session.bootId !== input.bootId) return result("boot-mismatch");
|
|
146
|
+
if (session.changed) return result("source-changed");
|
|
147
|
+
const before = input.before.find((p) => p.pid === session.pid);
|
|
148
|
+
const after = input.after.find((p) => p.pid === session.pid);
|
|
149
|
+
if (!before) return result("not-observed");
|
|
150
|
+
provenance.push(
|
|
151
|
+
`/proc/${session.pid}/stat`,
|
|
152
|
+
`/proc/${session.pid}/exe`,
|
|
153
|
+
`/proc/${session.pid}/ns/pid`,
|
|
154
|
+
);
|
|
155
|
+
if (!sameProcess(before, after)) return result("process-changed");
|
|
156
|
+
if (before.harness !== session.harness) return result("harness-mismatch");
|
|
157
|
+
if (session.procStart && session.procStart !== before.startTicks) return result("birth-mismatch");
|
|
158
|
+
if (session.source === "process-scan") return result("process-only", before);
|
|
159
|
+
if (session.source === "codex-open-rollout") {
|
|
160
|
+
uncertainties.push("open-descriptor-is-not-writer-or-current-display");
|
|
161
|
+
return result("open-descriptor-associated", before);
|
|
162
|
+
}
|
|
163
|
+
if (session.source === "pi-broker") {
|
|
164
|
+
uncertainties.push(
|
|
165
|
+
"publisher-has-no-process-birth-token",
|
|
166
|
+
"self-report-not-current-display-proof",
|
|
167
|
+
);
|
|
168
|
+
if (
|
|
169
|
+
!session.updatedAt ||
|
|
170
|
+
session.updatedAt < input.startedAt - 12000 ||
|
|
171
|
+
session.updatedAt > input.endedAt + BIRTH_CLOCK_ALLOWANCE_MS
|
|
172
|
+
)
|
|
173
|
+
return result("stale-publisher", before);
|
|
174
|
+
if (session.startedAt < before.birthMs - BIRTH_CLOCK_ALLOWANCE_MS)
|
|
175
|
+
return result("birth-mismatch");
|
|
176
|
+
return result("reported", before);
|
|
177
|
+
}
|
|
178
|
+
if (!session.pidDomain || !session.procStart) {
|
|
179
|
+
uncertainties.push("registry-missing-pid-domain-or-birth-token");
|
|
180
|
+
return result("unconfirmed", before);
|
|
181
|
+
}
|
|
182
|
+
uncertainties.push("registry-has-no-boot-id");
|
|
183
|
+
const delta = session.startedAt - before.birthMs;
|
|
184
|
+
if (!Number.isFinite(delta) || Math.abs(delta) > BIRTH_CLOCK_ALLOWANCE_MS) {
|
|
185
|
+
uncertainties.push("registry-startedAt-differs-from-process-birth");
|
|
186
|
+
return result("uncertain-start-time", before);
|
|
187
|
+
}
|
|
188
|
+
return result("birth-correlated", before);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {ProcessObservation | null} process @param {RecoveryInputs} input */
|
|
192
|
+
function windowLinks(process, input) {
|
|
193
|
+
if (!process)
|
|
194
|
+
return {
|
|
195
|
+
state: "unresolved",
|
|
196
|
+
windows: [],
|
|
197
|
+
provenance: [],
|
|
198
|
+
uncertainties: ["no-stable-process-link"],
|
|
199
|
+
};
|
|
200
|
+
/** @type {ProcessObservation[]} */
|
|
201
|
+
const chain = [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
let current = process;
|
|
204
|
+
while (current && !seen.has(current.pid) && chain.length < 64) {
|
|
205
|
+
seen.add(current.pid);
|
|
206
|
+
if (
|
|
207
|
+
!sameProcess(
|
|
208
|
+
current,
|
|
209
|
+
input.after.find((p) => p.pid === current.pid),
|
|
210
|
+
)
|
|
211
|
+
) {
|
|
212
|
+
return { state: "changed", windows: [], provenance: [], uncertainties: ["ancestor-changed"] };
|
|
213
|
+
}
|
|
214
|
+
chain.push(current);
|
|
215
|
+
const parent = input.before.find((p) => p.pid === current.ppid);
|
|
216
|
+
if (!parent) break;
|
|
217
|
+
current = parent;
|
|
218
|
+
}
|
|
219
|
+
const candidates = input.windows.filter((w) => chain.some((p) => p.pid === w.pid));
|
|
220
|
+
const stable = candidates.filter((w) =>
|
|
221
|
+
input.windowsAfter.some(
|
|
222
|
+
(a) => a.id === w.id && a.pid === w.pid && a.workspace_id === w.workspace_id,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
const changed =
|
|
226
|
+
stable.length !== candidates.length ||
|
|
227
|
+
input.windowsAfter.some(
|
|
228
|
+
(w) => chain.some((p) => p.pid === w.pid) && !candidates.some((b) => b.id === w.id),
|
|
229
|
+
);
|
|
230
|
+
const windows = stable.map((w) => {
|
|
231
|
+
const ws = input.workspaces.find((s) => s.id === w.workspace_id);
|
|
232
|
+
const wsStable = ws && input.workspacesAfter.some((a) => a.id === ws.id && a.idx === ws.idx);
|
|
233
|
+
return {
|
|
234
|
+
id: w.id,
|
|
235
|
+
hostPid: w.pid,
|
|
236
|
+
workspaceId: w.workspace_id,
|
|
237
|
+
workspaceIndex: wsStable ? ws.idx : null,
|
|
238
|
+
relation: "process-ancestry-containment",
|
|
239
|
+
provenance: [
|
|
240
|
+
"niri msg -j windows",
|
|
241
|
+
...chain.map((p) => `/proc/${p.pid}/stat`),
|
|
242
|
+
"niri msg -j workspaces",
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
state: changed
|
|
248
|
+
? "changed"
|
|
249
|
+
: windows.length > 1
|
|
250
|
+
? "ambiguous"
|
|
251
|
+
: windows.length === 1
|
|
252
|
+
? "containment-only"
|
|
253
|
+
: "not-observed",
|
|
254
|
+
windows,
|
|
255
|
+
provenance: ["niri msg -j windows (before/after)"],
|
|
256
|
+
uncertainties: [
|
|
257
|
+
"containment-is-not-exact-tab-identity",
|
|
258
|
+
...(changed ? ["window-observation-changed"] : []),
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @param {RecoveryInputs} input @param {{includePaths?: boolean}} [options] */
|
|
264
|
+
|
|
265
|
+
export function buildRecoveryManifest(input, { includePaths = false } = {}) {
|
|
266
|
+
const observations = withProcessOnlyObservations(input);
|
|
267
|
+
const sessions = observations.map((s, index) => {
|
|
268
|
+
const process = processLink(s, input);
|
|
269
|
+
const claimant = s.sessionId ? claimantFor(s.harness, s.sessionId) : null;
|
|
270
|
+
return {
|
|
271
|
+
observationId: `observation-${index + 1}`,
|
|
272
|
+
harness: s.harness,
|
|
273
|
+
session: {
|
|
274
|
+
id: s.sessionId,
|
|
275
|
+
source: s.source,
|
|
276
|
+
provenance: [s.sourceRef],
|
|
277
|
+
cwd: s.cwd,
|
|
278
|
+
reportedStartedAt: s.startedAt || null,
|
|
279
|
+
},
|
|
280
|
+
process,
|
|
281
|
+
placement: windowLinks(process.process, input),
|
|
282
|
+
transcripts: s.transcripts,
|
|
283
|
+
ak: {
|
|
284
|
+
claimant,
|
|
285
|
+
authority: "AK-only; observation does not transfer custody",
|
|
286
|
+
links:
|
|
287
|
+
/** @type {Array<{taskId: number; state: string; claimant: string; leaseExpiresAt: string; provenance: string[]}>} */ ([]),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
const claims = input.claims.map((c) => {
|
|
292
|
+
const claimant = parseClaimant(c.claimed_by);
|
|
293
|
+
const lease = Date.parse(c.lease_expires_at);
|
|
294
|
+
const matched = claimant ? sessions.filter((s) => s.ak.claimant === claimant.claimant) : [];
|
|
295
|
+
const state = !claimant
|
|
296
|
+
? "unsupported-claimant"
|
|
297
|
+
: !Number.isFinite(lease)
|
|
298
|
+
? "invalid-lease"
|
|
299
|
+
: lease <= input.endedAt
|
|
300
|
+
? "expired"
|
|
301
|
+
: c.status !== "claimed"
|
|
302
|
+
? "not-claimed"
|
|
303
|
+
: matched.length > 1
|
|
304
|
+
? "ambiguous-session"
|
|
305
|
+
: matched.length === 1
|
|
306
|
+
? "exact-claimant"
|
|
307
|
+
: "session-not-observed";
|
|
308
|
+
if (state === "exact-claimant")
|
|
309
|
+
matched[0].ak.links.push({
|
|
310
|
+
taskId: c.id,
|
|
311
|
+
state,
|
|
312
|
+
claimant: c.claimed_by,
|
|
313
|
+
leaseExpiresAt: c.lease_expires_at,
|
|
314
|
+
provenance: ["ak task list --status claimed --format json --all --verbose"],
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
taskId: c.id,
|
|
318
|
+
claimant: c.claimed_by,
|
|
319
|
+
harness: claimant?.harness ?? null,
|
|
320
|
+
leaseExpiresAt: c.lease_expires_at,
|
|
321
|
+
state,
|
|
322
|
+
observationIds: matched.map((s) => s.observationId),
|
|
323
|
+
provenance: ["ak task list --status claimed --format json --all --verbose"],
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
const manifest = {
|
|
327
|
+
schema: MANIFEST_SCHEMA,
|
|
328
|
+
readOnly: true,
|
|
329
|
+
complete: false,
|
|
330
|
+
observation: {
|
|
331
|
+
startedAt: new Date(input.startedAt).toISOString(),
|
|
332
|
+
endedAt: new Date(input.endedAt).toISOString(),
|
|
333
|
+
bootId: input.bootId,
|
|
334
|
+
pidDomain: input.pidDomain,
|
|
335
|
+
atomic: false,
|
|
336
|
+
},
|
|
337
|
+
privacy: {
|
|
338
|
+
paths: includePaths ? "included-private-output-only" : "sha256-redacted",
|
|
339
|
+
transcriptContent: "never-emitted",
|
|
340
|
+
titles: "never-emitted",
|
|
341
|
+
commandLines: "never-emitted",
|
|
342
|
+
},
|
|
343
|
+
capabilities: RECOVERY_CAPABILITIES,
|
|
344
|
+
sources: input.sources,
|
|
345
|
+
coverage: {
|
|
346
|
+
observationRows: sessions.length,
|
|
347
|
+
identifiedSessionObservations: sessions.filter((s) => s.session.id).length,
|
|
348
|
+
distinctSessionIdentities: new Set(
|
|
349
|
+
sessions.filter((s) => s.session.id).map((s) => `${s.harness}:${s.session.id}`),
|
|
350
|
+
).size,
|
|
351
|
+
processOnlyObservations: sessions.filter((s) => !s.session.id).length,
|
|
352
|
+
processCandidates: input.before.filter((p) => p.harness).length,
|
|
353
|
+
processLinks: sessions.filter((s) => s.process.process).length,
|
|
354
|
+
processLinkStates: counts(sessions.map((s) => s.process.state)),
|
|
355
|
+
harnesses: Object.fromEntries(
|
|
356
|
+
[...new Set(sessions.map((s) => s.harness))].map((h) => [
|
|
357
|
+
h,
|
|
358
|
+
{
|
|
359
|
+
observationRows: sessions.filter((s) => s.harness === h).length,
|
|
360
|
+
distinctSessionIdentities: new Set(
|
|
361
|
+
sessions.filter((s) => s.harness === h && s.session.id).map((s) => s.session.id),
|
|
362
|
+
).size,
|
|
363
|
+
processOnlyObservations: sessions.filter((s) => s.harness === h && !s.session.id)
|
|
364
|
+
.length,
|
|
365
|
+
},
|
|
366
|
+
]),
|
|
367
|
+
),
|
|
368
|
+
placementStates: counts(sessions.map((s) => s.placement.state)),
|
|
369
|
+
transcripts: counts(sessions.flatMap((s) => s.transcripts.map((t) => t.state))),
|
|
370
|
+
claimRows: claims.length,
|
|
371
|
+
claimStates: counts(claims.map((c) => c.state)),
|
|
372
|
+
},
|
|
373
|
+
limitations: [
|
|
374
|
+
"Non-atomic bounded observation; processes can start or exit after capture.",
|
|
375
|
+
"No full historical session census or transcript/context completeness claim.",
|
|
376
|
+
"No exact terminal tab/current display proof; no reopen, focus or replay command.",
|
|
377
|
+
"AK6737 owns custody; AK6481 owns native reopen; this is observation only.",
|
|
378
|
+
],
|
|
379
|
+
sessions,
|
|
380
|
+
claims,
|
|
381
|
+
windowContainmentGroups: [
|
|
382
|
+
...new Set(sessions.flatMap((s) => s.placement.windows.map((w) => w.id))),
|
|
383
|
+
].map((windowId) => {
|
|
384
|
+
const contained = sessions.filter((s) => s.placement.windows.some((w) => w.id === windowId));
|
|
385
|
+
return {
|
|
386
|
+
windowId,
|
|
387
|
+
relation: "candidate-containment-not-session-count",
|
|
388
|
+
processObservations: new Set(contained.map((s) => s.process.process?.pid).filter(Boolean))
|
|
389
|
+
.size,
|
|
390
|
+
distinctSessionIdentities: new Set(
|
|
391
|
+
contained.filter((s) => s.session.id).map((s) => `${s.harness}:${s.session.id}`),
|
|
392
|
+
).size,
|
|
393
|
+
observationIds: contained.map((s) => s.observationId),
|
|
394
|
+
};
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
397
|
+
return includePaths ? manifest : redactPaths(manifest);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** @param {string[]} values */
|
|
401
|
+
|
|
402
|
+
function counts(values) {
|
|
403
|
+
return Object.fromEntries(
|
|
404
|
+
[...new Set(values)].sort().map((key) => [key, values.filter((v) => v === key).length]),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** @template T @param {T} value @returns {T} */
|
|
409
|
+
function redactPaths(value) {
|
|
410
|
+
return JSON.parse(
|
|
411
|
+
JSON.stringify(value, (_key, item) =>
|
|
412
|
+
typeof item === "string" && item.startsWith("/")
|
|
413
|
+
? `path-sha256:${createHash("sha256").update(item).digest("hex")}`
|
|
414
|
+
: item,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// summary: Read-only recovery observation types; not AK custody or reopen authority.
|
|
2
|
+
export interface ProcessObservation {
|
|
3
|
+
pid: number;
|
|
4
|
+
ppid: number;
|
|
5
|
+
startTicks: string;
|
|
6
|
+
birthMs: number;
|
|
7
|
+
bootId: string;
|
|
8
|
+
pidDomain: string;
|
|
9
|
+
executable: string;
|
|
10
|
+
executableIdentity: string;
|
|
11
|
+
selectorDigest?: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
harness: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SessionObservation {
|
|
16
|
+
harness: string;
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
pid: number;
|
|
19
|
+
source: string;
|
|
20
|
+
sourceRef: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
pidDomain: string | null;
|
|
24
|
+
procStart: string | null;
|
|
25
|
+
bootId?: string | null;
|
|
26
|
+
changed: boolean;
|
|
27
|
+
updatedAt?: number;
|
|
28
|
+
transcripts: TranscriptObservation[];
|
|
29
|
+
}
|
|
30
|
+
export interface TranscriptObservation {
|
|
31
|
+
path: string;
|
|
32
|
+
state: string;
|
|
33
|
+
method: string;
|
|
34
|
+
identity?: string;
|
|
35
|
+
size?: number;
|
|
36
|
+
changed?: boolean;
|
|
37
|
+
sessionId?: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface WindowObservation {
|
|
40
|
+
id: number;
|
|
41
|
+
pid: number | null;
|
|
42
|
+
workspace_id: number | null;
|
|
43
|
+
}
|
|
44
|
+
export interface WorkspaceObservation {
|
|
45
|
+
id: number;
|
|
46
|
+
idx: number;
|
|
47
|
+
}
|
|
48
|
+
export interface SourceCoverage {
|
|
49
|
+
source: string;
|
|
50
|
+
state: string;
|
|
51
|
+
observed: number;
|
|
52
|
+
rejected: number;
|
|
53
|
+
limitations: string[];
|
|
54
|
+
}
|
|
55
|
+
export interface ClaimObservation {
|
|
56
|
+
id: number;
|
|
57
|
+
claimed_by: string;
|
|
58
|
+
status: string;
|
|
59
|
+
lease_expires_at: string;
|
|
60
|
+
}
|
|
61
|
+
export interface RecoveryInputs {
|
|
62
|
+
startedAt: number;
|
|
63
|
+
endedAt: number;
|
|
64
|
+
bootId: string;
|
|
65
|
+
pidDomain: string;
|
|
66
|
+
before: ProcessObservation[];
|
|
67
|
+
after: ProcessObservation[];
|
|
68
|
+
sessions: SessionObservation[];
|
|
69
|
+
windows: WindowObservation[];
|
|
70
|
+
windowsAfter: WindowObservation[];
|
|
71
|
+
workspaces: WorkspaceObservation[];
|
|
72
|
+
workspacesAfter: WorkspaceObservation[];
|
|
73
|
+
claims: ClaimObservation[];
|
|
74
|
+
sources: SourceCoverage[];
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "renders only allowlisted ribbon observation fields, never arbitrary diagnostic text"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing diagnostic output or its redaction boundary"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { normalizeKeyboardObservation } from "./keyboard-observation.mjs";
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} value @param {string[]} allowed */
|
|
10
|
+
const enumValue = (value, allowed) =>
|
|
11
|
+
typeof value === "string" && allowed.includes(value) ? value : "unknown";
|
|
12
|
+
/** @param {unknown} value */
|
|
13
|
+
const hashValue = (value) =>
|
|
14
|
+
typeof value === "string" && /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
|
|
15
|
+
|
|
16
|
+
/** @param {import("./contracts.ts").ActivityStripRuntimeStatus["ribbonDiagnostics"]} diagnostics */
|
|
17
|
+
export function formatRibbonDiagnostics(diagnostics) {
|
|
18
|
+
if (!diagnostics) return ["ribbon diagnostics: not reported by this controller"];
|
|
19
|
+
const build = diagnostics.panelBuild;
|
|
20
|
+
const native = normalizeKeyboardObservation({
|
|
21
|
+
...diagnostics.nativeKeyboardObservation,
|
|
22
|
+
protocol: 2,
|
|
23
|
+
type: "keyboard-observation",
|
|
24
|
+
});
|
|
25
|
+
return [
|
|
26
|
+
`visibility reason: ${enumValue(diagnostics.visibilityReason, ["starting", "error", "unproven-focus", "non-agent-focus", "no-admitted-owner", "awaiting-panel-visibility", "eligible-agent-context", "eligible-presence-context", "persistent-workspace"])}`,
|
|
27
|
+
`keyboard authorization: ${enumValue(diagnostics.keyboardAuthorization, ["none", "hover", "explicit"])} (not proof of capture)`,
|
|
28
|
+
...(native
|
|
29
|
+
? [
|
|
30
|
+
`capture expiry: last native observation (${native.origin}; ${native.reason}), not current authority`,
|
|
31
|
+
`native monotonic ms: ${native.nativeMs}; idle deadline ms: ${native.deadlineMs ?? "not applicable (explicit)"}; request: ${native.request} (same native process clock only; not a countdown)`,
|
|
32
|
+
]
|
|
33
|
+
: ["capture expiry: not observed (native monotonic clock)"]),
|
|
34
|
+
`private protocol: expected ${Number.isSafeInteger(diagnostics.expectedPrivateProtocol) ? diagnostics.expectedPrivateProtocol : "unknown"}, receipt ${Number.isSafeInteger(build?.privateProtocol) ? build.privateProtocol : "unknown"}`,
|
|
35
|
+
`panel receipt: ${enumValue(build?.state, ["pending", "unavailable", "changed-during-observation", "incompatible", "receipt-matched", "receipt-mismatch"])}`,
|
|
36
|
+
`panel PID executable sha256: ${hashValue(build?.binarySha256)} (sampled; not custody proof)`,
|
|
37
|
+
`panel build sampled at: ${Number.isSafeInteger(diagnostics.panelBuildObservedAt) ? diagnostics.panelBuildObservedAt : "unknown"} (stale after process changes)`,
|
|
38
|
+
`receipted native source sha256: ${hashValue(build?.receiptedSourceSha256)}`,
|
|
39
|
+
`controller startup source sha256: ${hashValue(diagnostics.controllerStartupSourceSha256)} (observation, not loaded-byte proof)`,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
/** @typedef {import("./contracts.ts").BrokerResponse} BrokerResponse */
|
|
8
8
|
|
|
9
|
+
import { formatRibbonDiagnostics } from "./ribbon-status.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* When the AK deferral list was read and how long it may be reused; empty for unusable values.
|
|
13
|
+
* @param {{akTaskDeferredReadAt?: number | null; akTaskDeferredMaxAgeMs?: number}} runtime
|
|
14
|
+
*/
|
|
15
|
+
function deferralListAge(runtime) {
|
|
16
|
+
const readAt = runtime.akTaskDeferredReadAt;
|
|
17
|
+
if (typeof readAt !== "number" || !Number.isFinite(readAt) || Math.abs(readAt) > 8.64e15)
|
|
18
|
+
return "";
|
|
19
|
+
const maxAge = runtime.akTaskDeferredMaxAgeMs;
|
|
20
|
+
const reuse =
|
|
21
|
+
typeof maxAge === "number" && Number.isFinite(maxAge) && maxAge > 0
|
|
22
|
+
? `, reused up to ${Math.round(maxAge / 1000)} s`
|
|
23
|
+
: "";
|
|
24
|
+
return `; deferral list read ${new Date(readAt).toISOString()}${reuse}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
/** @param {BrokerResponse | null | undefined} result */
|
|
10
28
|
export function formatBrokerRuntimeStatus(result) {
|
|
11
29
|
if (!result?.ok) return "stopped";
|
|
@@ -26,6 +44,14 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
26
44
|
if (typeof runtime.windowVisible === "boolean") {
|
|
27
45
|
lines.push(`surface visible: ${runtime.windowVisible ? "yes" : "no"}`);
|
|
28
46
|
}
|
|
47
|
+
if (typeof runtime.panelExpanded === "boolean") {
|
|
48
|
+
lines.push(
|
|
49
|
+
runtime.panelExpanded && runtime.windowVisible === false
|
|
50
|
+
? "expansion: stale (hidden surface)"
|
|
51
|
+
: `expansion: ${runtime.panelExpanded ? "expanded" : "compact"} (reported; cause not observed)`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
lines.push(...formatRibbonDiagnostics(runtime.ribbonDiagnostics));
|
|
29
55
|
if (typeof runtime.rendererCardCount === "number") {
|
|
30
56
|
const hidden =
|
|
31
57
|
typeof runtime.rendererHiddenTabCardCount === "number"
|
|
@@ -64,7 +90,7 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
64
90
|
if (runtime.akTaskState === "ok") {
|
|
65
91
|
const orphaned = Number(runtime.akTaskOrphanCount ?? 0);
|
|
66
92
|
lines.push(
|
|
67
|
-
`AK task refs: ok (${runtime.akTaskClaimCount ?? 0} live claims, ${runtime.akTaskDeferredCount ?? 0} deferred${orphaned > 0 ? `, ${orphaned} orphaned claims` : ""})`,
|
|
93
|
+
`AK task refs: ok (${runtime.akTaskClaimCount ?? 0} live claims, ${runtime.akTaskDeferredCount ?? 0} deferred${orphaned > 0 ? `, ${orphaned} orphaned claims` : ""}${deferralListAge(runtime)})`,
|
|
68
94
|
);
|
|
69
95
|
} else if (runtime.akTaskState !== "disabled") {
|
|
70
96
|
lines.push(
|
|
@@ -101,10 +127,19 @@ export function summarizeBrokerRuntimeStatus(result) {
|
|
|
101
127
|
|
|
102
128
|
const runtime = result.runtimeStatus;
|
|
103
129
|
if (!runtime) {
|
|
104
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
headline: "Activity strip broker is running; overlay readiness is unknown",
|
|
132
|
+
level: "warning",
|
|
133
|
+
};
|
|
105
134
|
}
|
|
106
135
|
|
|
107
136
|
if (runtime.state === "ready") {
|
|
137
|
+
if (runtime.tabInventoryState === "failed" || runtime.tabInventoryState === "unavailable") {
|
|
138
|
+
return {
|
|
139
|
+
headline: `Activity strip overlay is ready; tab inventory ${runtime.tabInventoryState}`,
|
|
140
|
+
level: "warning",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
108
143
|
return { headline: "Activity strip is running and ready", level: "info" };
|
|
109
144
|
}
|
|
110
145
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
7
|
import { canVerifyTabVisibility, placeSessionWindow } from "./niri-focus.mjs";
|
|
8
|
+
import { isPresenceApp } from "./presence-apps.mjs";
|
|
8
9
|
import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
|
|
9
10
|
import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
|
|
10
11
|
import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
|
|
@@ -40,6 +41,50 @@ export function resolveSurfaceVisibility(window, session, placement, options = {
|
|
|
40
41
|
return "unknown";
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** @typedef {"agent" | "neutral" | "non-agent" | "unknown" | "none"} FocusKind */
|
|
45
|
+
/**
|
|
46
|
+
* Classify exact admitted Niri ownership, never app-id/title heuristics. Card ownership alone
|
|
47
|
+
* makes a window "agent"; a focused presence application that owns no exact card is "neutral":
|
|
48
|
+
* the strip stays on screen over it and the window itself, not a card, is the interaction source.
|
|
49
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
50
|
+
* @param {Record<string, unknown>} workspace
|
|
51
|
+
* @param {Array<Record<string, unknown>>} cards
|
|
52
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
53
|
+
* @returns {FocusKind}
|
|
54
|
+
*/
|
|
55
|
+
export function classifyWorkspaceFocus(windows, workspace, cards, env = process.env) {
|
|
56
|
+
if (
|
|
57
|
+
windows.some(
|
|
58
|
+
(window) => !Number.isInteger(window?.id) || typeof window?.is_focused !== "boolean",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return "unknown";
|
|
62
|
+
const focused = windows.filter((window) => window.is_focused === true);
|
|
63
|
+
// Niri retains an active window while an exclusive layer surface owns the keyboard. NULL
|
|
64
|
+
// alone is not ownership: only the controller's pending explicit request can admit it.
|
|
65
|
+
if (focused.length === 0) return windows.length > 0 ? "none" : "unknown";
|
|
66
|
+
if (focused.length !== 1) return "unknown";
|
|
67
|
+
const window = focused[0];
|
|
68
|
+
if (!Number.isInteger(window.id) || window.workspace_id !== workspace.id) return "unknown";
|
|
69
|
+
return classifyAdmittedWindowFocus(Number(window.id), cards, window, env);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Exact Niri focus events may update eligibility before a slower full projection finishes.
|
|
73
|
+
* Without the window's own facts presence cannot be proven, so the strict result stands.
|
|
74
|
+
* @param {number} windowId @param {Array<Record<string, unknown>>} cards
|
|
75
|
+
* @param {Record<string, unknown> | null} [window] @param {NodeJS.ProcessEnv} [env]
|
|
76
|
+
* @returns {FocusKind}
|
|
77
|
+
*/
|
|
78
|
+
export function classifyAdmittedWindowFocus(windowId, cards, window = null, env = process.env) {
|
|
79
|
+
if (!Number.isInteger(windowId)) return "unknown";
|
|
80
|
+
const visible = cards.filter(
|
|
81
|
+
(card) => card.windowId === windowId && card.surfaceVisible !== false,
|
|
82
|
+
).length;
|
|
83
|
+
if (visible === 1) return "agent";
|
|
84
|
+
if (window?.id === windowId && isPresenceApp(window.app_id, env)) return "neutral";
|
|
85
|
+
return visible === 0 ? "non-agent" : "unknown";
|
|
86
|
+
}
|
|
87
|
+
|
|
43
88
|
/**
|
|
44
89
|
* Project the global broker snapshot onto one exact Niri workspace. Membership requires an exact
|
|
45
90
|
* window placement: a visible title, or a hidden tab attributed through its Ghostty host process.
|
|
@@ -129,8 +174,17 @@ export function resolveWorkspaceView(windows, workspace, sessions, options = {})
|
|
|
129
174
|
windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
|
|
130
175
|
);
|
|
131
176
|
const focusedCard = focusedCandidates.length === 1 ? focusedCandidates[0] : null;
|
|
177
|
+
const focusedWindows = windows.filter((window) => window?.is_focused === true);
|
|
178
|
+
const focusWindow =
|
|
179
|
+
focusedWindows.length === 1 &&
|
|
180
|
+
Number.isInteger(focusedWindows[0]?.id) &&
|
|
181
|
+
focusedWindows[0].workspace_id === workspace.id
|
|
182
|
+
? Number(focusedWindows[0].id)
|
|
183
|
+
: null;
|
|
132
184
|
return {
|
|
133
185
|
workspace,
|
|
186
|
+
focusKind: classifyWorkspaceFocus(windows, workspace, projectedSessions, options.env),
|
|
187
|
+
focusWindowId: focusWindow,
|
|
134
188
|
sessions: projectedSessions,
|
|
135
189
|
focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
|
|
136
190
|
focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
|