@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
@@ -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
+ }
@@ -17,14 +17,26 @@ import { resolveFocusedWorkspaceView, resolveWindowDirectory } from "../common/w
17
17
  import { discoverAgentTabs } from "./agent-discovery.mjs";
18
18
  import { startAkTaskProjection } from "./ak-runtime.mjs";
19
19
  import { createHeightRepair } from "./height-repair.mjs";
20
+ import { createHoverKeyboard } from "./hover-keyboard.mjs";
21
+ import { createKeyboardEntry } from "./keyboard-entry.mjs";
20
22
  import { resolvePanelBinary } from "./panel-binary.mjs";
21
23
  import { createNativePanelProjection } from "./panel-projection.mjs";
24
+ import { admitPanelEvent, PANEL_PROTOCOL } from "./panel-protocol.mjs";
25
+ import { createPanelWindowJump } from "./panel-window-jump.mjs";
22
26
  import { createPlacementRuntime } from "./placement.mjs";
27
+ import {
28
+ createRuntimeStatusReader,
29
+ recordNativeKeyboardObservation,
30
+ } from "./runtime-diagnostics.mjs";
31
+ import { resolveStripHotkey } from "./strip-hotkey.mjs";
23
32
  import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
24
- import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
33
+ import {
34
+ createNiriWorkspaceEventWatcher,
35
+ createWorkspaceProjectionHandlers,
36
+ } from "./workspace-events.mjs";
25
37
 
26
38
  /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
27
- /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
39
+ /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_protocolError?: string; _activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
28
40
  /** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
29
41
 
30
42
  const execFileAsync = promisify(execFile);
@@ -80,8 +92,6 @@ function isNiriSession() {
80
92
  return Boolean(process.env.NIRI_SOCKET);
81
93
  }
82
94
 
83
- // Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
84
- // memory learned from visible titles and the bounded AT-SPI tab inventory.
85
95
  const placement = createPlacementRuntime({
86
96
  env: process.env,
87
97
  runtimeStatus,
@@ -96,7 +106,9 @@ function writePanel(message) {
96
106
  return false;
97
107
  }
98
108
  try {
99
- const accepted = panel.stdin.write(`${JSON.stringify(message)}\n`);
109
+ const accepted = panel.stdin.write(
110
+ `${JSON.stringify({ ...message, protocol: PANEL_PROTOCOL })}\n`,
111
+ );
100
112
  if (!accepted) {
101
113
  panelWriteReady = false;
102
114
  const child = panel;
@@ -119,6 +131,11 @@ function writePanel(message) {
119
131
 
120
132
  const projection = createNativePanelProjection({
121
133
  isNiriSession,
134
+ // Presence ("neutral") focus needs the focused window's app id from the event cache.
135
+ getWindowFacts: (id) => placement.getObservedWindow(id),
136
+ env: process.env,
137
+ // Read once: the panel's progressive hints name the shortcut the compositor really binds.
138
+ hotkey: resolveStripHotkey({ env: process.env }),
122
139
  publish(view) {
123
140
  runtimeStatus.rendererCardCount = view.sessions.length;
124
141
  runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
@@ -133,8 +150,7 @@ const projection = createNativePanelProjection({
133
150
  },
134
151
  });
135
152
 
136
- // AK task references are a read-only projection of claims held by live sessions; the strip
137
- // never writes AK state and renders nothing when the read fails.
153
+ // Read-only AK task projection: never writes AK state or invents tasks on read failure.
138
154
  startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
139
155
 
140
156
  /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
@@ -158,48 +174,27 @@ async function focusSession(targetId) {
158
174
  return result;
159
175
  }
160
176
 
161
- /**
162
- * Jump to a window by the number Niri gives it, as typed into the window list. Only a window that
163
- * exists right now is focused; any other number does nothing.
164
- * @param {number} windowId
165
- * @returns {Promise<{ok: boolean; error?: string}>}
166
- */
167
- async function jumpToWindow(windowId) {
168
- if (!isNiriSession()) return { ok: false, error: "Niri is not available; nothing moved." };
169
- if (!Number.isInteger(windowId) || windowId < 0) {
170
- return { ok: false, error: "Not a window number; nothing moved." };
171
- }
172
- let windows;
173
- try {
174
- windows = await getNiriWindows();
175
- } catch {
176
- return { ok: false, error: "Could not read Niri windows; nothing moved." };
177
- }
178
- if (!windows.some((window) => window?.id === windowId)) {
179
- return { ok: false, error: `No window #${windowId}; nothing moved.` };
180
- }
181
- try {
182
- await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(windowId)], {
177
+ const requestWindowJump = createPanelWindowJump({
178
+ projection,
179
+ placementOptions: placement.options,
180
+ getWindowFacts: placement.getObservedWindow,
181
+ isNiriSession,
182
+ readWindows: () => getNiriWindows(),
183
+ readWorkspaces: () => getNiriWorkspaces(),
184
+ focusWindow: (id) =>
185
+ execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(id)], {
183
186
  env: process.env,
184
- });
185
- } catch {
186
- return { ok: false, error: `Niri refused to focus #${windowId}; nothing moved.` };
187
- }
188
- reconcileRunner.request();
189
- return { ok: true };
190
- }
187
+ }),
188
+ reconcile: () => reconcileRunner.request(),
189
+ });
191
190
 
192
- async function focusStrip() {
193
- if (runtimeStatus.clickThrough) {
194
- return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
195
- }
196
- if (!runtimeStatus.windowVisible || runtimeStatus.rendererCardCount === 0) {
197
- return { ok: false, error: "No visible native strip exists on this workspace." };
198
- }
199
- return writePanel({ type: "focus-strip" })
200
- ? { ok: true }
201
- : { ok: false, error: "Native panel is not ready for keyboard entry." };
202
- }
191
+ const requestKeyboardEntry = createKeyboardEntry({
192
+ projection,
193
+ runtimeStatus,
194
+ writePanel,
195
+ });
196
+ const focusStrip = async () => requestKeyboardEntry();
197
+ let hoverKeyboard = createHoverKeyboard({ projection, runtimeStatus, writePanel });
203
198
 
204
199
  /** @param {PanelChild} child @param {unknown} chunk */
205
200
  function consumePanelEvents(child, chunk) {
@@ -212,13 +207,14 @@ function consumePanelEvents(child, chunk) {
212
207
  if (line) {
213
208
  try {
214
209
  const event = JSON.parse(line);
215
- if (event.protocol !== 1) throw new Error("unsupported native panel protocol");
210
+ if (!admitPanelEvent(child, event, runtimeStatus, () => projection.cancelKeyboard()))
211
+ return;
216
212
  if (event.type === "ready") {
217
213
  clearTimeout(child._readyTimer);
218
214
  panelReady = true;
219
215
  runtimeStatus.state = "ready";
220
216
  runtimeStatus.readyAt = Date.now();
221
- runtimeStatus.panelPid = Number(event.pid) || child.pid || null;
217
+ runtimeStatus.panelPid = child.pid ?? null; // Supervised child, never a reported PID.
222
218
  runtimeStatus.error = null;
223
219
  clearTimeout(child._stableTimer);
224
220
  child._stableTimer = setTimeout(() => {
@@ -240,9 +236,20 @@ function consumePanelEvents(child, chunk) {
240
236
  const visible = event.visible === true;
241
237
  const changed = runtimeStatus.windowVisible !== visible;
242
238
  runtimeStatus.windowVisible = visible;
243
- // Showing or hiding the surface is the only moment the exclusive zone changes, so it is
244
- // the only moment a tiled window can be left at the previous working area's height.
239
+ // Zone changes can leave tiled window heights stale.
245
240
  if (changed) void heightRepair.repairAfterZoneChange();
241
+ } else if (event.type === "hover-request") {
242
+ hoverKeyboard.request(Number(event.token), Number(event.epoch));
243
+ } else if (event.type === "hover-cancel") {
244
+ if (hoverKeyboard.cancel(Number(event.token))) reconcileRunner.request();
245
+ } else if (event.type === "keyboard-observation") {
246
+ recordNativeKeyboardObservation(runtimeStatus, event, child.pid);
247
+ } else if (event.type === "keyboard-active") {
248
+ const accepted = projection.keyboardChanged(
249
+ Number(event.revision),
250
+ event.active === true,
251
+ );
252
+ if (accepted && event.active !== true) reconcileRunner.request();
246
253
  } else if (event.type === "expanded") {
247
254
  runtimeStatus.panelExpanded = event.expanded === true;
248
255
  } else if (event.type === "activate") {
@@ -265,7 +272,7 @@ function consumePanelEvents(child, chunk) {
265
272
  } else if (event.type === "jump") {
266
273
  runtimeStatus.panelJumpCount = (runtimeStatus.panelJumpCount ?? 0) + 1;
267
274
  const windowId = Number(event.windowId);
268
- void jumpToWindow(windowId).then((result) =>
275
+ void requestWindowJump(event).then((result) =>
269
276
  writePanel({
270
277
  type: "jump-result",
271
278
  windowId,
@@ -297,10 +304,12 @@ function startPanel() {
297
304
  stdio: ["pipe", "pipe", "pipe"],
298
305
  });
299
306
  panel = child;
307
+ hoverKeyboard = createHoverKeyboard({ projection, runtimeStatus, writePanel });
300
308
  panelReady = false;
301
309
  panelWriteReady = true;
302
310
  panelStderr = "";
303
311
  runtimeStatus.panelPid = child.pid ?? null;
312
+ runtimeStatus.nativeKeyboardObservation = null; // New supervised child, new native clock domain.
304
313
  runtimeStatus.state = "starting";
305
314
  child.stdout.setEncoding("utf8");
306
315
  child.stdin.on("error", () => {
@@ -323,9 +332,15 @@ function startPanel() {
323
332
  clearTimeout(child._stableTimer);
324
333
  if (panel !== child) return;
325
334
  panelReady = false;
335
+ projection.cancelKeyboard();
326
336
  runtimeStatus.windowVisible = false;
327
337
  runtimeStatus.panelPid = null;
328
338
  if (shuttingDown) return;
339
+ if (child._protocolError) {
340
+ runtimeStatus.state = "error";
341
+ runtimeStatus.error = child._protocolError;
342
+ return;
343
+ }
329
344
  if (panelRestartCount < 3) {
330
345
  panelRestartCount += 1;
331
346
  runtimeStatus.panelRestartCount = panelRestartCount;
@@ -379,7 +394,7 @@ const theme = createThemeRuntime({
379
394
  execFileAsync,
380
395
  env: process.env,
381
396
  runtimeStatus,
382
- publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
397
+ publish: (definitions) => writePanel({ protocol: PANEL_PROTOCOL, type: "theme", definitions }),
383
398
  });
384
399
 
385
400
  const heightRepair = createHeightRepair({
@@ -403,7 +418,7 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
403
418
  const sessions = projection.getRawSessions();
404
419
  runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
405
420
  const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
406
- if (view) placement.learnFromPlacements(view.sessions, windows);
421
+ placement.learnFromPlacements(view?.sessions ?? [], windows, sessions);
407
422
  projection.setDirectory(resolveWindowDirectory(windows, workspaces, sessions, placement.options));
408
423
  projection.publishWorkspaceView(
409
424
  view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
@@ -417,7 +432,7 @@ async function shutdown(exitCode = 0) {
417
432
  watcher?.stop();
418
433
  placement.persist();
419
434
  writePanel({
420
- protocol: 1,
435
+ protocol: PANEL_PROTOCOL,
421
436
  type: "view",
422
437
  revision: Number.MAX_SAFE_INTEGER,
423
438
  visible: false,
@@ -437,14 +452,10 @@ async function main() {
437
452
  broker = await createActivityStripBroker({
438
453
  focusSession,
439
454
  focusStrip,
440
- getRuntimeStatus: () => ({
441
- ...runtimeStatus,
442
- warnings: [...(runtimeStatus.warnings ?? [])],
443
- }),
455
+ getRuntimeStatus: createRuntimeStatusReader({ runtimeStatus, projection }),
444
456
  });
445
457
  broker.on("snapshot", (snapshot) => {
446
- // Compare like with like: the raw set also holds discovered agent tabs, which the broker
447
- // never publishes, so comparing it to a broker snapshot would always differ and reconcile.
458
+ // Compare broker-only membership; discovered agent tabs must not force reconciliation.
448
459
  const membershipChanged = !haveSameRecordMembership(
449
460
  projection.getBrokerSessions(),
450
461
  snapshot.sessions,
@@ -460,16 +471,11 @@ async function main() {
460
471
  watcher = createNiriWorkspaceEventWatcher({
461
472
  spawn,
462
473
  env: process.env,
463
- onFocusedWorkspace: () => reconcileRunner.request(),
464
- onFallback: () => reconcileRunner.request(),
465
- onWindowChanged: (window) => {
466
- if (placement.observeWindowEvent(window)) reconcileRunner.request();
467
- },
468
- onWindowClosed: (windowId) => {
469
- if (placement.forgetWindow(windowId)) reconcileRunner.request();
470
- },
471
- onWindowFocusChanged: () => reconcileRunner.request(),
472
- onWorkspacesChanged: () => reconcileRunner.request(),
474
+ ...createWorkspaceProjectionHandlers({
475
+ projection,
476
+ placement,
477
+ reconcile: () => reconcileRunner.request(),
478
+ }),
473
479
  fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
474
480
  });
475
481
  }