@tryinget/pi-activity-strip 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +59 -21
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +4 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +1141 -84
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +471 -0
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +5 -0
  17. package/native/panel/src/protocol.rs +93 -8
  18. package/native/panel/src/runtime.rs +85 -12
  19. package/native/panel/src/style.css +85 -0
  20. package/package.json +6 -7
  21. package/src/broker/server.mjs +31 -14
  22. package/src/client/launcher.mjs +3 -7
  23. package/src/client/runtime-lock.mjs +2 -1
  24. package/src/common/agent-identity.mjs +6 -1
  25. package/src/common/contracts.d.ts +2 -0
  26. package/src/common/contracts.ts +39 -0
  27. package/src/common/keyboard-observation.mjs +35 -0
  28. package/src/common/overlay-readiness.mjs +11 -0
  29. package/src/common/presence-apps.mjs +57 -0
  30. package/src/common/recovery-identity.mjs +85 -0
  31. package/src/common/recovery-manifest.mjs +417 -0
  32. package/src/common/recovery-types.ts +75 -0
  33. package/src/common/ribbon-status.mjs +41 -0
  34. package/src/common/status-report.mjs +37 -2
  35. package/src/common/workspace-view.mjs +118 -0
  36. package/src/native/ak-runtime.mjs +50 -9
  37. package/src/native/focus-visibility.mjs +67 -0
  38. package/src/native/ghostty-tab-inventory.py +40 -23
  39. package/src/native/hover-keyboard.mjs +32 -0
  40. package/src/native/interaction-context.mjs +45 -0
  41. package/src/native/keyboard-entry.mjs +32 -0
  42. package/src/native/main.mjs +91 -42
  43. package/src/native/panel-binary.mjs +25 -8
  44. package/src/native/panel-neutral-source.mjs +53 -0
  45. package/src/native/panel-presentation.mjs +137 -0
  46. package/src/native/panel-projection.mjs +354 -18
  47. package/src/native/panel-protocol.mjs +23 -0
  48. package/src/native/panel-window-jump.mjs +90 -0
  49. package/src/native/placement.mjs +83 -20
  50. package/src/native/recovery-inventory.mjs +209 -0
  51. package/src/native/recovery-proc.mjs +178 -0
  52. package/src/native/recovery-sources.mjs +358 -0
  53. package/src/native/runtime-diagnostics.mjs +242 -0
  54. package/src/native/strip-hotkey-kdl.mjs +252 -0
  55. package/src/native/strip-hotkey.mjs +62 -0
  56. package/src/native/tab-inventory.mjs +62 -11
  57. package/src/native/window-jump.mjs +43 -0
  58. package/src/native/workspace-events.mjs +123 -2
@@ -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,
@@ -149,6 +203,70 @@ export function resolveFocusedWorkspaceView(windows, workspaces, sessions, optio
149
203
  return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
150
204
  }
151
205
 
206
+ /**
207
+ * @typedef {{
208
+ * cardId: string;
209
+ * label: string;
210
+ * windowId: number;
211
+ * workspaceId: number;
212
+ * workspaceIdx: number | null;
213
+ * workspaceName: string | null;
214
+ * hiddenTab: boolean;
215
+ * current: boolean;
216
+ * }} WindowDirectoryEntry
217
+ */
218
+
219
+ /**
220
+ * Every agent window on every workspace, for the ribbon's window list. It reuses the per-workspace
221
+ * card projection, so an entry exists exactly when that workspace would show the card. Ordered
222
+ * alphabetically by project name, then by window number; each entry carries its workspace.
223
+ * @param {Array<Record<string, unknown>>} windows
224
+ * @param {Array<Record<string, unknown>>} workspaces
225
+ * @param {Array<Record<string, unknown>>} sessions
226
+ * @param {ResolveOptions} [options]
227
+ * @returns {{entries: WindowDirectoryEntry[]; cards: Map<string, Record<string, unknown>>}}
228
+ */
229
+ export function resolveWindowDirectory(windows, workspaces, sessions, options = {}) {
230
+ const ordered = workspaces
231
+ .filter((workspace) => Number.isInteger(workspace?.id))
232
+ .sort(
233
+ (a, b) =>
234
+ String(a.output ?? "").localeCompare(String(b.output ?? "")) ||
235
+ Number(a.idx ?? 0) - Number(b.idx ?? 0),
236
+ );
237
+ /** @type {WindowDirectoryEntry[]} */
238
+ const entries = [];
239
+ /** @type {Map<string, Record<string, unknown>>} */
240
+ const cards = new Map();
241
+ for (const workspace of ordered) {
242
+ const view = resolveWorkspaceView(windows, workspace, sessions, options);
243
+ if (!view) continue;
244
+ const placed = view.sessions
245
+ .filter((card) => Number.isInteger(card.windowId))
246
+ .sort((a, b) => Number(a.windowId) - Number(b.windowId));
247
+ for (const card of placed) {
248
+ const cardId = String(card.cardId ?? "");
249
+ if (!cardId || cards.has(cardId)) continue;
250
+ cards.set(cardId, card);
251
+ entries.push({
252
+ cardId,
253
+ label: String(card.repoLabel || "session"),
254
+ windowId: Number(card.windowId),
255
+ workspaceId: Number(workspace.id),
256
+ workspaceIdx: Number.isInteger(card.workspaceIdx) ? Number(card.workspaceIdx) : null,
257
+ workspaceName: typeof workspace.name === "string" ? workspace.name : null,
258
+ hiddenTab: card.surfaceVisible === false,
259
+ current: cardId === view.focusedCardId,
260
+ });
261
+ }
262
+ }
263
+ entries.sort(
264
+ (a, b) =>
265
+ a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.windowId - b.windowId,
266
+ );
267
+ return { entries, cards };
268
+ }
269
+
152
270
  /** @param {Array<Record<string, unknown>>} workspaces */
153
271
  export function resolveFocusedNiriWorkspace(workspaces) {
154
272
  const matches = workspaces.filter(
@@ -13,6 +13,14 @@ import {
13
13
 
14
14
  /** AK state changes are rare; a calm clock keeps the ribbon cheap. */
15
15
  export const AK_TASK_REFRESH_MS = 15_000;
16
+ /**
17
+ * The full deferral list held the workstation-wide AK lock 3.3 s of every 15 s (AK7943): reuse the
18
+ * last good list for up to five minutes while claims keep the 15 s clock. It is read only after the
19
+ * claims read succeeds, is dropped when its own read fails, expires when the clock steps back, and is
20
+ * reread at once when a newer live claim names a task it still holds as deferred (AK refuses to
21
+ * claim a task with an active deferral, so that claim proves the list stale).
22
+ */
23
+ export const AK_DEFERRED_REFRESH_MS = 300_000;
16
24
  const AK_EXEC_TIMEOUT_MS = 8000;
17
25
  const AK_EXEC_MAX_BUFFER = 4 * 1024 * 1024;
18
26
 
@@ -40,6 +48,8 @@ export function createAkTaskRuntime({
40
48
  /** @type {NodeJS.Timeout | null} */
41
49
  let timer = null;
42
50
  let inFlight = false;
51
+ /** @type {{at: number; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
52
+ let deferredCache = null;
43
53
 
44
54
  runtimeStatus.akTaskState = disabled ? "disabled" : (runtimeStatus.akTaskState ?? "pending");
45
55
  runtimeStatus.akTaskError = runtimeStatus.akTaskError ?? null;
@@ -63,32 +73,61 @@ export function createAkTaskRuntime({
63
73
  /**
64
74
  * @param {string} error
65
75
  */
66
- function failClosed(error) {
76
+ function failClosed(error, { keepDeferred = false } = {}) {
67
77
  runtimeStatus.akTaskState = "unavailable";
68
78
  runtimeStatus.akTaskError = error;
69
79
  runtimeStatus.akTaskClaimCount = 0;
70
80
  runtimeStatus.akTaskDeferredCount = 0;
81
+ runtimeStatus.akTaskDeferredReadAt = null;
82
+ if (!keepDeferred) deferredCache = null;
71
83
  onChange({ claims: [], deferred: [] });
72
84
  }
73
85
 
74
86
  async function refresh() {
75
87
  if (disabled || inFlight) return false;
76
88
  inFlight = true;
89
+ let phase = "claims";
77
90
  try {
78
- const [claimRaw, deferredRaw] = await Promise.all([
79
- readCli(["task", "list", "--status", "claimed", "--format", "json", "--all", "--verbose"]),
80
- readCli(["task", "deferred", "--format", "json", "--all"]),
81
- ]);
82
- const claims = parseAkClaimList(claimRaw);
83
- const deferred = parseAkDeferredList(deferredRaw);
91
+ const readAt = now();
92
+ // Claims first: a failing or contended claim read never also spends the deferral read.
93
+ const claims = parseAkClaimList(
94
+ await readCli([
95
+ "task",
96
+ "list",
97
+ "--status",
98
+ "claimed",
99
+ "--format",
100
+ "json",
101
+ "--all",
102
+ "--verbose",
103
+ ]),
104
+ );
84
105
  if (!claims.ok) {
85
- failClosed(claims.error);
106
+ failClosed(claims.error, { keepDeferred: true });
86
107
  return false;
87
108
  }
109
+ const cache = deferredCache;
110
+ const cachedIds = new Set(cache ? cache.deferred.map((task) => task.id) : []);
111
+ const contradicted =
112
+ cache !== null &&
113
+ claims.claims.some(
114
+ (claim) =>
115
+ cachedIds.has(claim.id) && claim.claimedAt > cache.at && claimIsLive(claim, readAt),
116
+ );
117
+ const age = cache ? readAt - cache.at : Number.NaN;
118
+ const fresh =
119
+ cache && !contradicted && age >= 0 && age < AK_DEFERRED_REFRESH_MS ? cache : null;
120
+ phase = "deferred";
121
+ const deferred = fresh
122
+ ? { ok: /** @type {const} */ (true), deferred: fresh.deferred }
123
+ : parseAkDeferredList(await readCli(["task", "deferred", "--format", "json", "--all"]));
88
124
  if (!deferred.ok) {
89
125
  failClosed(deferred.error);
90
126
  return false;
91
127
  }
128
+ deferredCache = fresh ?? { at: readAt, deferred: deferred.deferred };
129
+ runtimeStatus.akTaskDeferredReadAt = deferredCache.at;
130
+ runtimeStatus.akTaskDeferredMaxAgeMs = AK_DEFERRED_REFRESH_MS;
92
131
  runtimeStatus.akTaskState = "ok";
93
132
  runtimeStatus.akTaskError = null;
94
133
  runtimeStatus.akTaskClaimCount = claims.claims.filter((claim) =>
@@ -98,7 +137,9 @@ export function createAkTaskRuntime({
98
137
  onChange({ claims: claims.claims, deferred: deferred.deferred });
99
138
  return true;
100
139
  } catch (error) {
101
- failClosed(`ak CLI unavailable: ${error instanceof Error ? error.message : String(error)}`);
140
+ failClosed(`ak CLI unavailable: ${error instanceof Error ? error.message : String(error)}`, {
141
+ keepDeferred: phase === "claims",
142
+ });
102
143
  return false;
103
144
  } finally {
104
145
  inFlight = false;
@@ -0,0 +1,67 @@
1
+ /** @typedef {import("../common/workspace-view.mjs").FocusKind} FocusKind */
2
+ /** Controller-authorized hover/explicit ownership across transient Niri NULL focus. */
3
+ export function createFocusVisibility() {
4
+ /** @type {FocusKind} */
5
+ let focus = "unknown";
6
+ /** @type {unknown} */
7
+ let workspaceId = null;
8
+ /** @type {number | null} */
9
+ let keyboardRequest = null;
10
+ let eligible = false;
11
+ /** @type {"none" | "hover" | "explicit"} */
12
+ let mode = "none";
13
+ /** @type {number | null} */
14
+ let hoverToken = null;
15
+ /** A window the strip may stay present over and take an interaction source from. */
16
+ const isSource = (/** @type {FocusKind} */ kind) => kind === "agent" || kind === "neutral";
17
+ function reset() {
18
+ keyboardRequest = null;
19
+ mode = "none";
20
+ hoverToken = null;
21
+ if (!isSource(focus)) eligible = false;
22
+ }
23
+ return {
24
+ /** @param {{workspaceId: unknown; focus: FocusKind; hasCards: boolean}} next */
25
+ observe(next) {
26
+ if (
27
+ next.workspaceId !== workspaceId ||
28
+ !next.hasCards ||
29
+ !(isSource(next.focus) || next.focus === "none")
30
+ )
31
+ reset();
32
+ workspaceId = next.workspaceId;
33
+ focus = next.focus;
34
+ eligible =
35
+ next.hasCards && (isSource(focus) || (focus === "none" && keyboardRequest !== null));
36
+ return eligible;
37
+ },
38
+ /** Reserve BEFORE the panel write: NULL may precede the acknowledgement.
39
+ * @param {number} request @param {boolean} clickThrough
40
+ * @param {"hover" | "explicit"} [kind] @param {number | null} [token]
41
+ */
42
+ requestKeyboard(request, clickThrough, kind = "explicit", token = null) {
43
+ if (!eligible || clickThrough || (kind === "hover" && mode !== "none")) return false;
44
+ keyboardRequest = request;
45
+ mode = kind;
46
+ hoverToken = kind === "hover" ? token : null;
47
+ return true;
48
+ },
49
+ /** Late acknowledgements cannot revive invalidated ownership. @param {number} request @param {boolean} active */
50
+ keyboardChanged(request, active) {
51
+ if (request !== keyboardRequest) return false;
52
+ if (!active) reset();
53
+ return true;
54
+ },
55
+ resetKeyboard: reset,
56
+ /** Never cancel a newer hover or explicit upgrade. @param {number} token */
57
+ cancelHover(token) {
58
+ if (mode !== "hover" || token !== hoverToken) return false;
59
+ reset();
60
+ return true;
61
+ },
62
+ keyboardMode: () => mode,
63
+ /** @param {number} request */
64
+ ownsRequest: (request) => request === keyboardRequest,
65
+ isEligible: () => eligible,
66
+ };
67
+ }
@@ -9,7 +9,8 @@
9
9
  Usage: ghostty-tab-inventory.py <pid> [<pid> ...]
10
10
 
11
11
  Output: {"ok": true, "frames": [{"pid": int, "name": str, "tabs": [str, ...]}, ...]}
12
- Exit 3 with {"ok": false, "error": ...} when the AT-SPI Python bindings are unavailable.
12
+ Failures emit {"ok": false, "stage": fixed_label, "reason": fixed_label}.
13
+ Exit 3 only when the AT-SPI Python bindings are unavailable; enumeration failures exit 1.
13
14
  The script only reads accessibility state; it never activates, selects, or focuses anything.
14
15
  """
15
16
 
@@ -20,15 +21,19 @@ MAX_NODES_PER_FRAME = 20000
20
21
  MAX_DEPTH = 40
21
22
 
22
23
 
24
+ def fail(stage, reason, code=1):
25
+ print(json.dumps({"ok": False, "stage": stage, "reason": reason}))
26
+ return code
27
+
28
+
23
29
  def main() -> int:
24
30
  try:
25
31
  import gi
26
32
 
27
33
  gi.require_version("Atspi", "2.0")
28
34
  from gi.repository import Atspi
29
- except Exception as error: # noqa: BLE001 - any import failure means the capability is absent
30
- print(json.dumps({"ok": False, "error": f"AT-SPI bindings unavailable: {error}"}))
31
- return 3
35
+ except Exception: # noqa: BLE001 - any import failure means the capability is absent
36
+ return fail("import", "bindings-unavailable", 3)
32
37
 
33
38
  pids = {int(argument) for argument in sys.argv[1:] if argument.isdigit()}
34
39
  if not pids:
@@ -68,29 +73,41 @@ def main() -> int:
68
73
  return labels
69
74
 
70
75
  frames = []
71
- desktop = Atspi.get_desktop(0)
72
- for index in range(desktop.get_child_count()):
73
- application = desktop.get_child_at_index(index)
74
- if application is None:
75
- continue
76
- try:
77
- pid = application.get_process_id()
78
- except Exception: # noqa: BLE001
79
- continue
80
- if pid not in pids:
81
- continue
82
- for window_index in range(application.get_child_count()):
83
- frame = application.get_child_at_index(window_index)
84
- if frame is None:
76
+ stage = "desktop-lookup"
77
+ try:
78
+ desktop = Atspi.get_desktop(0)
79
+ if desktop is None:
80
+ return fail(stage, "desktop-unavailable")
81
+ stage = "desktop-count"
82
+ application_count = desktop.get_child_count()
83
+ for index in range(application_count):
84
+ stage = "desktop-index"
85
+ application = desktop.get_child_at_index(index)
86
+ if application is None:
85
87
  continue
86
88
  try:
87
- if frame.get_role() != Atspi.Role.FRAME:
88
- continue
89
- name = frame.get_name() or ""
89
+ pid = application.get_process_id()
90
90
  except Exception: # noqa: BLE001
91
91
  continue
92
- frames.append({"pid": pid, "name": name, "tabs": tab_labels(frame)})
93
-
92
+ if pid not in pids:
93
+ continue
94
+ stage = "application-count"
95
+ window_count = application.get_child_count()
96
+ for window_index in range(window_count):
97
+ stage = "application-index"
98
+ frame = application.get_child_at_index(window_index)
99
+ if frame is None:
100
+ continue
101
+ try:
102
+ if frame.get_role() != Atspi.Role.FRAME:
103
+ continue
104
+ name = frame.get_name() or ""
105
+ except Exception: # noqa: BLE001
106
+ continue
107
+ frames.append({"pid": pid, "name": name, "tabs": tab_labels(frame)})
108
+ except Exception as error: # noqa: BLE001 - never report a partial top-level inventory
109
+ reason = "access-denied" if isinstance(error, PermissionError) else "enumeration-failed"
110
+ return fail(stage, reason)
94
111
  print(json.dumps({"ok": True, "frames": frames}))
95
112
  return 0
96
113
 
@@ -0,0 +1,32 @@
1
+ /** Bounded panel-request/controller-grant handshake, using the production admission state.
2
+ * No GTK focus signal is an authority source. No deferred command is queued on write failure.
3
+ * @param {{
4
+ * projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
5
+ * runtimeStatus: {clickThrough?: boolean; windowVisible?: boolean};
6
+ * writePanel: (message: {type: string; token: number; epoch: number; request?: number}) => boolean;
7
+ * }} options
8
+ */
9
+ export function createHoverKeyboard({ projection, runtimeStatus, writePanel }) {
10
+ // Tokens are unique for the lifetime of one panel. This binding is recreated on panel restart.
11
+ let lastToken = 0;
12
+ return {
13
+ /** @param {number} token @param {number} epoch */
14
+ request(token, epoch) {
15
+ if (!Number.isSafeInteger(token) || token <= lastToken) return false;
16
+ lastToken = token;
17
+ const request =
18
+ runtimeStatus.windowVisible && !runtimeStatus.clickThrough
19
+ ? projection.requestHoverKeyboard(epoch, token, false)
20
+ : null;
21
+ if (request !== null) {
22
+ if (writePanel({ type: "hover-grant", token, epoch, request })) return true;
23
+ projection.cancelHoverKeyboard(token);
24
+ } else {
25
+ writePanel({ type: "hover-denied", token, epoch });
26
+ }
27
+ return false;
28
+ },
29
+ /** @param {number} token */
30
+ cancel: (token) => projection.cancelHoverKeyboard(token),
31
+ };
32
+ }
@@ -0,0 +1,45 @@
1
+ /** Exact-window ownership cardinality shared by every projection proof.
2
+ * @param {Record<string, unknown>[]} sessions @param {number | null} windowId */
3
+ export function visibleCardsForWindow(sessions, windowId) {
4
+ return sessions.filter((card) => card.windowId === windowId && card.surfaceVisible !== false);
5
+ }
6
+
7
+ /** Recheck the same exact agent card/publisher set against a complete cached view.
8
+ * @param {Record<string, unknown>[]} sessions
9
+ * @param {{workspace: Record<string, unknown> | null; sessions: Record<string, unknown>[]} | null} current
10
+ * @param {unknown} workspaceId @param {number | null} windowId */
11
+ export function agentSourceMatches(sessions, current, workspaceId, windowId) {
12
+ const established = visibleCardsForWindow(sessions, windowId);
13
+ const owned = visibleCardsForWindow(current?.sessions ?? [], windowId);
14
+ return (
15
+ current?.workspace?.id === workspaceId &&
16
+ established.length === 1 &&
17
+ owned.length === 1 &&
18
+ owned[0]?.cardId === established[0]?.cardId &&
19
+ JSON.stringify(publisherSet(owned[0])) === JSON.stringify(publisherSet(established[0]))
20
+ );
21
+ }
22
+
23
+ /** Layout selection can contradict a retained source but never establish seat focus.
24
+ * Missing fields support older snapshots; an explicit NULL or malformed id fails closed.
25
+ * @param {Record<string, unknown> | null} workspace @param {number | null} sourceId */
26
+ export function selectionContradictsSource(workspace, sourceId) {
27
+ return (
28
+ sourceId !== null &&
29
+ Object.hasOwn(workspace ?? {}, "active_window_id") &&
30
+ workspace?.active_window_id !== sourceId
31
+ );
32
+ }
33
+
34
+ /** Publisher order is presentation, never identity. @param {Record<string, unknown> | undefined} card */
35
+ export function publisherSet(card) {
36
+ return [
37
+ ...new Set(
38
+ Array.isArray(card?.publisherRecordKeys) ? card.publisherRecordKeys.map(String) : [],
39
+ ),
40
+ ].sort();
41
+ }
42
+ /** @param {unknown} workspaceId @param {number | null} windowId @param {Record<string, unknown> | undefined} card */
43
+ export function sourceIdentity(workspaceId, windowId, card) {
44
+ return JSON.stringify([workspaceId, windowId, card?.cardId, publisherSet(card)]);
45
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Admit against the current compositor projection/event facts, not panel activity or card count.
3
+ * Reserve before writing: Niri's NULL focus event may arrive before the panel acknowledgement.
4
+ * No asynchronous queue may take the keyboard after this command has reported failure.
5
+ * @param {{
6
+ * projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
7
+ * runtimeStatus: {clickThrough?: boolean; windowVisible?: boolean};
8
+ * writePanel: (message: {type: string; revision: number; epoch: number}) => boolean;
9
+ * }} options
10
+ */
11
+ export function createKeyboardEntry({ projection, runtimeStatus, writePanel }) {
12
+ return function focusStrip() {
13
+ if (runtimeStatus.clickThrough) {
14
+ return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
15
+ }
16
+ if (!runtimeStatus.windowVisible || !projection.isEligible()) {
17
+ return { ok: false, error: "No eligible visible native strip exists on this workspace." };
18
+ }
19
+ const request = projection.requestKeyboard();
20
+ if (
21
+ request !== null &&
22
+ writePanel({
23
+ type: "focus-strip",
24
+ revision: request,
25
+ epoch: projection.getInteractionEpoch(),
26
+ })
27
+ )
28
+ return { ok: true };
29
+ projection.cancelKeyboard();
30
+ return { ok: false, error: "Native panel is not ready for keyboard entry." };
31
+ };
32
+ }