@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.
Files changed (57) hide show
  1. package/README.md +59 -22
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +3 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +572 -266
  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 +79 -17
  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 +4 -0
  17. package/native/panel/src/protocol.rs +22 -8
  18. package/native/panel/src/runtime.rs +43 -2
  19. package/package.json +5 -5
  20. package/src/broker/server.mjs +31 -14
  21. package/src/client/launcher.mjs +3 -7
  22. package/src/client/runtime-lock.mjs +2 -1
  23. package/src/common/agent-identity.mjs +6 -1
  24. package/src/common/contracts.d.ts +1 -0
  25. package/src/common/contracts.ts +38 -0
  26. package/src/common/keyboard-observation.mjs +35 -0
  27. package/src/common/overlay-readiness.mjs +11 -0
  28. package/src/common/presence-apps.mjs +57 -0
  29. package/src/common/recovery-identity.mjs +85 -0
  30. package/src/common/recovery-manifest.mjs +417 -0
  31. package/src/common/recovery-types.ts +75 -0
  32. package/src/common/ribbon-status.mjs +41 -0
  33. package/src/common/status-report.mjs +37 -2
  34. package/src/common/workspace-view.mjs +54 -0
  35. package/src/native/ak-runtime.mjs +50 -9
  36. package/src/native/focus-visibility.mjs +67 -0
  37. package/src/native/ghostty-tab-inventory.py +40 -23
  38. package/src/native/hover-keyboard.mjs +32 -0
  39. package/src/native/interaction-context.mjs +45 -0
  40. package/src/native/keyboard-entry.mjs +32 -0
  41. package/src/native/main.mjs +77 -71
  42. package/src/native/panel-binary.mjs +25 -8
  43. package/src/native/panel-neutral-source.mjs +53 -0
  44. package/src/native/panel-presentation.mjs +137 -0
  45. package/src/native/panel-projection.mjs +338 -24
  46. package/src/native/panel-protocol.mjs +23 -0
  47. package/src/native/panel-window-jump.mjs +90 -0
  48. package/src/native/placement.mjs +83 -20
  49. package/src/native/recovery-inventory.mjs +209 -0
  50. package/src/native/recovery-proc.mjs +178 -0
  51. package/src/native/recovery-sources.mjs +358 -0
  52. package/src/native/runtime-diagnostics.mjs +242 -0
  53. package/src/native/strip-hotkey-kdl.mjs +252 -0
  54. package/src/native/strip-hotkey.mjs +62 -0
  55. package/src/native/tab-inventory.mjs +62 -11
  56. package/src/native/window-jump.mjs +43 -0
  57. 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 { headline: "Activity strip is running", level: "info" };
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,