@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
@@ -3,23 +3,39 @@
3
3
  // read_when:
4
4
  // - "changing native panel card membership, focus, or transport revisions"
5
5
  // ---
6
-
7
6
  import { joinAkTaskChips } from "../common/ak-tasks.mjs";
8
7
  import { resolveSnapshotSession } from "../common/niri-focus.mjs";
9
8
  import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
9
+ import { classifyAdmittedWindowFocus } from "../common/workspace-view.mjs";
10
+ import { createFocusVisibility } from "./focus-visibility.mjs";
11
+ import {
12
+ agentSourceMatches,
13
+ publisherSet,
14
+ selectionContradictsSource,
15
+ sourceIdentity,
16
+ visibleCardsForWindow,
17
+ } from "./interaction-context.mjs";
18
+ import { createNeutralSource } from "./panel-neutral-source.mjs";
19
+ import { createPanelPresentation } from "./panel-presentation.mjs";
20
+ import { PANEL_PROTOCOL } from "./panel-protocol.mjs";
10
21
 
11
22
  /** @typedef {Record<string, unknown>} SessionRecord */
12
23
  /** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
13
- /** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
24
+ /** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null; focusWindowId?: number | null; focusKind?: import("./focus-visibility.mjs").FocusKind}} WorkspaceView */
14
25
  /** @typedef {{placement: string; surfaceVisible: boolean; windowId: number | null; workspaceIdx: number | null}} CardPlacement */
15
- /** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
26
+ /** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void; getWindowFacts?: (id: number) => Record<string, unknown> | null; env?: NodeJS.ProcessEnv; hotkey?: string | null}} NativePanelProjectionOptions */
16
27
 
17
28
  /** @param {NativePanelProjectionOptions} options */
18
- export function createNativePanelProjection({ isNiriSession, publish }) {
29
+ export function createNativePanelProjection({
30
+ isNiriSession,
31
+ publish,
32
+ getWindowFacts = () => null,
33
+ env = process.env,
34
+ hotkey = null,
35
+ }) {
19
36
  /** @type {Snapshot} */
20
37
  let snapshot = { generatedAt: Date.now(), sessions: [] };
21
- /** Agent tabs discovered from the process table; they publish no telemetry of their own. */
22
- /** @type {SessionRecord[]} */
38
+ /** Process-discovered agent tabs publish no telemetry. @type {SessionRecord[]} */
23
39
  let agentSessions = [];
24
40
  /** @type {string | null} */
25
41
  let focusedSessionId = null;
@@ -33,15 +49,79 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
33
49
  let workspaceRecordKeys = new Set();
34
50
  /** @type {Map<string, CardPlacement>} */
35
51
  let workspacePlacements = new Map();
36
- /** Read-only AK task claims joined onto cards; absent until the first successful AK read. */
37
- /** @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
52
+ /** Read-only AK data; absent until first read. @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
38
53
  let akTasks = null;
39
- /** Every agent window on every workspace, for the brand block's window list. */
40
- /** @type {import("../common/workspace-view.mjs").WindowDirectoryEntry[]} */
54
+ /** All-workspace window list. @type {import("../common/workspace-view.mjs").WindowDirectoryEntry[]} */
41
55
  let directory = [];
42
56
  /** @type {Map<string, SessionRecord>} */
43
57
  let directoryCards = new Map();
44
58
  let revision = 0;
59
+ let nextKeyboardRequest = 0;
60
+ let interactionEpoch = 0;
61
+ let intentGeneration = 0;
62
+ let consumedJumpRequest = -1;
63
+ /** @type {string | null} */
64
+ let interactionContext = null;
65
+ const visibility = createFocusVisibility();
66
+ // Passive desktop presence is not permission to capture keys or activate a stale source.
67
+ const persistent = env.PI_ACTIVITY_STRIP_VISIBILITY !== "focus";
68
+ const keepsSurface = () => persistent && isNiriSession() && workspace?.is_focused === true;
69
+ /** @type {import("./focus-visibility.mjs").FocusKind} */
70
+ let focusKind = "unknown";
71
+ /** Exact source, retained across explicit panel NULL focus. @type {number | null} */
72
+ let focusWindowId = null;
73
+ /** One exact card, or a presence app owning none. @type {"agent" | "neutral" | null} */
74
+ let focusSourceKind = null;
75
+ let focusContextValid = false;
76
+
77
+ const neutralSource = createNeutralSource(getWindowFacts, env);
78
+ const presentation = createPanelPresentation({
79
+ getWindowFacts,
80
+ getCards: getDisplaySessions,
81
+ getRawSessions: allSessions,
82
+ env,
83
+ });
84
+
85
+ function ensureNeutralSource() {
86
+ if (
87
+ focusSourceKind !== "neutral" ||
88
+ neutralSource.holds(workspace?.id, focusWindowId, getDisplaySessions())
89
+ )
90
+ return true;
91
+ invalidateFocus();
92
+ return false;
93
+ }
94
+
95
+ function clearFocusState() {
96
+ focusContextValid = false;
97
+ focusKind = "unknown";
98
+ focusWindowId = null;
99
+ focusSourceKind = null;
100
+ neutralSource.clear();
101
+ focusedCardId = null;
102
+ focusedSessionId = null;
103
+ }
104
+
105
+ function invalidateFocus() {
106
+ presentation.clear();
107
+ clearFocusState();
108
+ send();
109
+ }
110
+
111
+ /** Recheck exact ownership: revoke only, never admit new membership from event facts.
112
+ * @param {(workspace: Record<string, unknown>) => WorkspaceView | null} resolveView
113
+ */
114
+ function focusProofMatches(resolveView) {
115
+ if (focusWindowId === null || !focusContextValid) return true;
116
+ const current = workspace ? resolveView(workspace) : null;
117
+ if (focusSourceKind === "neutral") {
118
+ return (
119
+ current?.workspace?.id === workspace?.id &&
120
+ neutralSource.holds(workspace?.id, focusWindowId, current?.sessions ?? [])
121
+ );
122
+ }
123
+ return agentSourceMatches(getDisplaySessions(), current, workspace?.id, focusWindowId);
124
+ }
45
125
 
46
126
  function allSessions() {
47
127
  return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
@@ -62,8 +142,7 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
62
142
  });
63
143
  }
64
144
  if (!akTasks) return cards;
65
- // Dead-session detection must consider every live session, including cards projected onto
66
- // other workspaces, so a claim is only orphaned when no live session anywhere holds it.
145
+ // Claims are orphaned only when no live session anywhere (including other workspaces) holds them.
67
146
  const liveSessionIds = new Set(
68
147
  rawSessions.map((session) => String(session.sessionId ?? "")).filter(Boolean),
69
148
  );
@@ -79,16 +158,66 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
79
158
  function currentView() {
80
159
  revision += 1;
81
160
  const sessions = getDisplaySessions();
161
+ if (
162
+ selectionContradictsSource(workspace, focusWindowId) ||
163
+ (focusSourceKind === "neutral" &&
164
+ !neutralSource.holds(workspace?.id, focusWindowId, sessions))
165
+ )
166
+ clearFocusState();
167
+ // NULL continues only an exact admitted owner, never another card after owner loss/ambiguity.
168
+ if (
169
+ focusSourceKind !== "neutral" &&
170
+ (focusKind === "agent" || (focusKind === "none" && focusWindowId !== null)) &&
171
+ (focusWindowId === null || visibleCardsForWindow(sessions, focusWindowId).length !== 1)
172
+ ) {
173
+ clearFocusState();
174
+ }
175
+ let owner =
176
+ focusSourceKind === "neutral"
177
+ ? undefined
178
+ : sessions.find((card) => card.windowId === focusWindowId && card.surfaceVisible !== false);
179
+ const rawOwner = owner
180
+ ? projectSessionCards(allSessions(), null).find((card) => card.cardId === owner?.cardId)
181
+ : undefined;
182
+ // Newly added publishers cannot inherit an admission established for a smaller set.
183
+ if (owner && JSON.stringify(publisherSet(owner)) !== JSON.stringify(publisherSet(rawOwner))) {
184
+ clearFocusState();
185
+ owner = undefined;
186
+ }
187
+ const facts = {
188
+ workspaceId: workspace?.id ?? null,
189
+ focus: isNiriSession() ? focusKind : "unknown",
190
+ hasCards: workspace?.is_focused === true && sessions.length > 0,
191
+ };
192
+ // Source identity survives own release/NULL, not app switch/admission loss; presence is separate.
193
+ const sourceHeld = focusSourceKind === "neutral" ? focusWindowId !== null : owner !== undefined;
194
+ const context =
195
+ focusContextValid &&
196
+ workspace?.is_focused === true &&
197
+ ["agent", "neutral", "none"].includes(focusKind) &&
198
+ sourceHeld
199
+ ? focusSourceKind === "neutral"
200
+ ? neutralSource.context(workspace?.id, focusWindowId)
201
+ : sourceIdentity(workspace?.id, focusWindowId, owner)
202
+ : null;
203
+ if (context !== interactionContext) {
204
+ interactionEpoch += 1;
205
+ visibility.resetKeyboard();
206
+ interactionContext = context;
207
+ }
208
+ const focusVisible = presentation.visible(visibility.observe(facts), workspace, focusWindowId);
82
209
  return {
83
- protocol: 1,
210
+ protocol: PANEL_PROTOCOL,
84
211
  type: "view",
85
212
  revision,
86
- visible: isNiriSession() ? Boolean(workspace?.is_focused && sessions.length > 0) : true,
213
+ interactionEpoch,
214
+ visible: keepsSurface() || focusVisible,
87
215
  focusedSessionId,
88
216
  focusedCardId,
89
217
  generatedAt: snapshot.generatedAt,
90
218
  sessions,
91
219
  windows: directory,
220
+ hotkey,
92
221
  };
93
222
  }
94
223
 
@@ -106,6 +235,159 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
106
235
 
107
236
  return {
108
237
  getDisplaySessions,
238
+ // Observe existing fields only: no publish, epoch advancement, admission or query access.
239
+ diagnostics: () => ({
240
+ persistentPresentation: keepsSurface(),
241
+ focus: focusKind,
242
+ source: focusSourceKind,
243
+ contextValid: focusContextValid,
244
+ eligible: visibility.isEligible(),
245
+ interactionEpoch,
246
+ keyboardAuthorization: visibility.keyboardMode(),
247
+ }),
248
+ /** Reserve ownership before sending; cancel on a failed write or panel exit. */
249
+ requestKeyboard: (clickThrough = false) => {
250
+ if (!ensureNeutralSource()) return null;
251
+ const request = ++nextKeyboardRequest;
252
+ if (!visibility.requestKeyboard(request, clickThrough)) return null;
253
+ intentGeneration += 1;
254
+ return request;
255
+ },
256
+ /** @param {number} epoch @param {number} token @param {boolean} clickThrough */
257
+ requestHoverKeyboard(epoch, token, clickThrough) {
258
+ if (!ensureNeutralSource() || epoch !== interactionEpoch) return null;
259
+ const request = ++nextKeyboardRequest;
260
+ if (!visibility.requestKeyboard(request, clickThrough, "hover", token)) return null;
261
+ intentGeneration += 1;
262
+ return request;
263
+ },
264
+ getInteractionEpoch: () => interactionEpoch,
265
+ keyboardMode: () => visibility.keyboardMode(),
266
+ /** @param {number} request */
267
+ ownsKeyboardRequest: (request) => visibility.ownsRequest(request),
268
+ /** @param {number} token */
269
+ cancelHoverKeyboard(token) {
270
+ const cancelled = visibility.cancelHover(token);
271
+ if (cancelled) intentGeneration += 1;
272
+ return cancelled;
273
+ },
274
+ cancelKeyboard() {
275
+ intentGeneration += 1;
276
+ visibility.resetKeyboard();
277
+ },
278
+ /** One accepted action, not a lease to capture keys. Own release must not cancel it.
279
+ * @param {number} request @param {number} epoch */
280
+ acceptJumpIntent(request, epoch) {
281
+ if (!ensureNeutralSource()) return null;
282
+ if (
283
+ !visibility.ownsRequest(request) ||
284
+ !visibility.isEligible() ||
285
+ epoch !== interactionEpoch ||
286
+ !interactionContext ||
287
+ !focusContextValid ||
288
+ focusWindowId === null ||
289
+ consumedJumpRequest === request
290
+ )
291
+ return null;
292
+ consumedJumpRequest = request;
293
+ const context = interactionContext;
294
+ const generation = intentGeneration;
295
+ const sourceWorkspaceId = workspace?.id;
296
+ const sourceWindowId = focusWindowId;
297
+ const sourceKind = focusSourceKind;
298
+ return {
299
+ workspaceId: sourceWorkspaceId,
300
+ windowId: sourceWindowId,
301
+ allowUnlisted: visibility.keyboardMode() === "explicit",
302
+ isCurrent: () =>
303
+ generation === intentGeneration &&
304
+ epoch === interactionEpoch &&
305
+ focusContextValid &&
306
+ context === interactionContext &&
307
+ sourceKind === focusSourceKind &&
308
+ ["agent", "neutral", "none"].includes(focusKind),
309
+ /** @param {WorkspaceView | null} view */
310
+ matchesSource(view) {
311
+ if (!view || view.workspace?.id !== sourceWorkspaceId) return false;
312
+ if (selectionContradictsSource(view.workspace, sourceWindowId)) return false;
313
+ const kind = view.focusKind ?? "unknown";
314
+ const owned = visibleCardsForWindow(view.sessions, sourceWindowId);
315
+ if (sourceKind === "neutral")
316
+ return (
317
+ (kind === "none" || (kind === "neutral" && view.focusWindowId === sourceWindowId)) &&
318
+ neutralSource.holds(sourceWorkspaceId, sourceWindowId, view.sessions) &&
319
+ neutralSource.context(sourceWorkspaceId, sourceWindowId) === context
320
+ );
321
+ if (!["agent", "none"].includes(kind)) return false;
322
+ return (
323
+ owned.length === 1 &&
324
+ (view.focusKind !== "agent" || view.focusedCardId === owned[0]?.cardId) &&
325
+ sourceIdentity(sourceWorkspaceId, sourceWindowId, owned[0]) === context
326
+ );
327
+ },
328
+ };
329
+ },
330
+ /** @param {number} request @param {boolean} active */
331
+ keyboardChanged: (request, active) => visibility.keyboardChanged(request, active),
332
+ isEligible: () => visibility.isEligible(),
333
+ invalidateFocus,
334
+ /** Revocation-only layout metadata; never admit seat focus or a replacement source.
335
+ * @param {number} id @param {number | null} windowId
336
+ * @param {(workspace: Record<string, unknown>) => WorkspaceView | null} resolveView */
337
+ observeWorkspaceSelection(id, windowId, resolveView) {
338
+ if (workspace?.id !== id) return;
339
+ workspace = { ...workspace, active_window_id: windowId };
340
+ presentation.prepare(workspace, windowId, resolveView);
341
+ send();
342
+ },
343
+ /** @param {number} windowId */
344
+ invalidateWindowFocus(windowId) {
345
+ if (windowId === focusWindowId || presentation.involves(windowId)) invalidateFocus();
346
+ },
347
+ /** @param {(workspace: Record<string, unknown>) => WorkspaceView | null} resolveView */
348
+ revalidateWindowFocus(resolveView) {
349
+ const valid = focusProofMatches(resolveView) && presentation.holds(workspace, resolveView);
350
+ if (!valid) invalidateFocus();
351
+ return valid;
352
+ },
353
+ /** Authoritative focus-event ids, not app-id/title guesses or delayed panel acknowledgements.
354
+ * @param {number | null} windowId
355
+ * @param {(workspace: Record<string, unknown>) => WorkspaceView | null} [resolveView]
356
+ */
357
+ publishWindowFocus(windowId, resolveView) {
358
+ if (windowId !== null && resolveView) presentation.prepare(workspace, windowId, resolveView);
359
+ const cards = getDisplaySessions();
360
+ const facts = windowId === null ? null : getWindowFacts(windowId);
361
+ focusKind = !focusContextValid
362
+ ? "unknown"
363
+ : windowId === null
364
+ ? "none"
365
+ : classifyAdmittedWindowFocus(
366
+ windowId,
367
+ cards,
368
+ facts?.workspace_id === workspace?.id ? facts : null,
369
+ env,
370
+ );
371
+ // NULL may be a layer surface: retain the source ring and scroll position, not input authority.
372
+ if (focusKind !== "none") {
373
+ const current =
374
+ focusKind === "agent"
375
+ ? cards.find((card) => card.windowId === windowId && card.surfaceVisible !== false)
376
+ : null;
377
+ focusedCardId = current ? String(current.cardId) : null;
378
+ focusedSessionId = current ? String(current.sessionId) : null;
379
+ }
380
+ if (focusKind === "agent" || focusKind === "neutral") {
381
+ focusWindowId = windowId;
382
+ focusSourceKind = focusKind;
383
+ neutralSource.admit(focusKind === "neutral" ? facts : null);
384
+ } else if (focusKind !== "none") {
385
+ focusWindowId = null;
386
+ focusSourceKind = null;
387
+ }
388
+ if (resolveView && !focusProofMatches(resolveView)) clearFocusState();
389
+ send();
390
+ },
109
391
  getRawSessions: allSessions,
110
392
  /** Broker-published records only, for comparisons against a broker snapshot. */
111
393
  getBrokerSessions: () => snapshot.sessions,
@@ -113,28 +395,29 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
113
395
  setAgentSessions(records) {
114
396
  agentSessions = Array.isArray(records) ? records : [];
115
397
  },
116
- /**
117
- * Replace the read-only AK task data joined onto cards. An empty refresh clears every chip;
118
- * `null` data (AK never read) leaves cards untouched, which is how the strip stays unchanged
119
- * when AK is absent.
398
+ /** Replace read-only AK chips: empty clears them; null (never read) leaves cards untouched.
120
399
  * @param {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} state
121
400
  */
122
401
  setAkTasks(state) {
123
402
  akTasks = state;
124
403
  send();
125
404
  },
126
- /**
127
- * A card on this workspace, or else an entry of the window list, which spans every workspace.
128
- * @param {string} targetId
129
- */
405
+ /** Resolve a current card or a cross-workspace directory entry.
406
+ * @param {string} targetId */
130
407
  resolveTarget: (targetId) =>
131
408
  resolveSnapshotSession(getDisplaySessions(), targetId) ??
132
409
  directoryCards.get(targetId) ??
133
410
  null,
134
411
  /** @param {number} windowId */
135
412
  hasDirectoryWindow: (windowId) => directory.some((entry) => entry.windowId === windowId),
136
- /**
137
- * Replace the window list; it is published with the next view.
413
+ /** @param {number} windowId */
414
+ getDirectoryWindowIdentities: (windowId) =>
415
+ directory
416
+ .filter((entry) => entry.windowId === windowId)
417
+ .map((entry) =>
418
+ sourceIdentity(entry.workspaceId, windowId, directoryCards.get(entry.cardId)),
419
+ ),
420
+ /** Replace the window list, published with the next view.
138
421
  * @param {{entries: import("../common/workspace-view.mjs").WindowDirectoryEntry[]; cards: Map<string, SessionRecord>}} next
139
422
  */
140
423
  setDirectory(next) {
@@ -143,7 +426,17 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
143
426
  },
144
427
  /** @param {WorkspaceView} view */
145
428
  publishWorkspaceView(view) {
429
+ presentation.prepareView(view, focusWindowId);
430
+ const sameWorkspace = Number.isInteger(workspace?.id) && view.workspace?.id === workspace?.id;
431
+ const retainedCardId = sameWorkspace ? focusedCardId : null;
432
+ const retainedSessionId = sameWorkspace ? focusedSessionId : null;
433
+ if (!sameWorkspace) {
434
+ focusWindowId = null;
435
+ focusSourceKind = null;
436
+ }
146
437
  workspace = view.workspace ?? null;
438
+ focusContextValid = Number.isInteger(workspace?.id) && workspace?.is_focused === true;
439
+ focusKind = view.focusKind ?? "unknown";
147
440
  workspaceCardIds = new Set(view.sessions.map((session) => String(session.cardId ?? "")));
148
441
  workspaceRecordKeys = new Set(
149
442
  view.sessions.flatMap((session) =>
@@ -167,6 +460,27 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
167
460
  );
168
461
  focusedSessionId = view.focusedSessionId;
169
462
  focusedCardId = view.focusedCardId ?? null;
463
+ // Under NULL the established source keeps its current card (see publishWindowFocus).
464
+ if (
465
+ focusKind === "none" &&
466
+ focusWindowId !== null &&
467
+ retainedCardId &&
468
+ workspaceCardIds.has(retainedCardId)
469
+ ) {
470
+ focusedCardId = retainedCardId;
471
+ focusedSessionId = retainedSessionId;
472
+ }
473
+ if (focusKind === "agent") {
474
+ focusWindowId = workspacePlacements.get(focusedCardId ?? "")?.windowId ?? null;
475
+ focusSourceKind = focusWindowId === null ? null : "agent";
476
+ } else if (focusKind === "neutral") {
477
+ focusWindowId = Number.isInteger(view.focusWindowId) ? Number(view.focusWindowId) : null;
478
+ focusSourceKind = focusWindowId === null ? null : "neutral";
479
+ neutralSource.admit(focusWindowId === null ? null : getWindowFacts(focusWindowId));
480
+ } else if (focusKind !== "none") {
481
+ focusWindowId = null;
482
+ focusSourceKind = null;
483
+ }
170
484
  send();
171
485
  },
172
486
  send,
@@ -0,0 +1,23 @@
1
+ // Private controller/rendering protocol ONLY. Pi publisher/broker message envelopes are unchanged.
2
+ export const PANEL_PROTOCOL = 2;
3
+ /** Fatal readiness/event gate, even for unverified development overrides. No view or command
4
+ * may be sent to an incompatible child, and a late ready cannot undo the rejection.
5
+ * @param {import("node:child_process").ChildProcessWithoutNullStreams & {_protocolError?: string}} child
6
+ * @param {{protocol?: unknown}} event
7
+ * @param {{state?: string; error?: string | null}} status @param {() => void} revoke */
8
+ export function admitPanelEvent(child, event, status, revoke) {
9
+ if (child._protocolError) return false;
10
+ if (event.protocol === PANEL_PROTOCOL) return true;
11
+ child._protocolError =
12
+ "Native panel private protocol mismatch: expected " +
13
+ PANEL_PROTOCOL +
14
+ ", received " +
15
+ String(event.protocol) +
16
+ ". Rebuild/use a matching controller and panel.";
17
+ revoke();
18
+ status.state = "error";
19
+ status.error = child._protocolError;
20
+ child.stdin.end();
21
+ child.kill("SIGTERM");
22
+ return false;
23
+ }
@@ -0,0 +1,90 @@
1
+ import { resolveFocusedWorkspaceView, resolveWindowDirectory } from "../common/workspace-view.mjs";
2
+ import { sourceIdentity } from "./interaction-context.mjs";
3
+ import { createWindowJump } from "./window-jump.mjs";
4
+ /** The SAME production binding is used by native event consumption and controlled pipe-race tests.
5
+ * @param {{projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
6
+ * getWindowFacts: (id: number) => Record<string, unknown> | null;
7
+ * placementOptions: import("../common/workspace-view.mjs").ResolveOptions;
8
+ * isNiriSession: () => boolean; readWindows: () => Promise<Record<string, unknown>[]>;
9
+ * readWorkspaces: () => Promise<Record<string, unknown>[]>;
10
+ * focusWindow: (id: number) => Promise<unknown>; reconcile: () => void}} options */
11
+ export function createPanelWindowJump({
12
+ projection,
13
+ placementOptions,
14
+ getWindowFacts,
15
+ ...options
16
+ }) {
17
+ /** @param {{windowId?: unknown; request?: unknown; epoch?: unknown}} event */
18
+ return async function requestJump(event) {
19
+ const intent = projection.acceptJumpIntent(Number(event.request), Number(event.epoch));
20
+ if (!intent)
21
+ return { ok: false, error: "Jump has no current authorized source intent; nothing moved." };
22
+ const sourceFacts = getWindowFacts(intent.windowId);
23
+ const windowId = Number(event.windowId);
24
+ const expectedTarget = projection.getDirectoryWindowIdentities(windowId);
25
+ /** @type {Record<string, unknown>[]} */
26
+ let workspaces = [];
27
+ const jump = createWindowJump({
28
+ ...options,
29
+ readWindows: async () => {
30
+ const [windows, freshWorkspaces] = await Promise.all([
31
+ options.readWindows(),
32
+ options.readWorkspaces(),
33
+ ]);
34
+ workspaces = freshWorkspaces;
35
+ return windows;
36
+ },
37
+ isAdmittedWindow: (id, windows) => {
38
+ if (!projection.hasDirectoryWindow(id)) return false;
39
+ const fresh = resolveWindowDirectory(
40
+ windows,
41
+ [...new Set(windows.map((w) => w.workspace_id))].map((id) => ({ id })),
42
+ projection.getRawSessions(),
43
+ placementOptions,
44
+ );
45
+ return fresh.entries.some(
46
+ (entry) =>
47
+ entry.windowId === id &&
48
+ expectedTarget.includes(
49
+ sourceIdentity(entry.workspaceId, id, fresh.cards.get(entry.cardId)),
50
+ ),
51
+ );
52
+ },
53
+ });
54
+ return jump(
55
+ Number(event.windowId),
56
+ intent.allowUnlisted && expectedTarget.length === 0,
57
+ (windows) =>
58
+ intent.isCurrent() &&
59
+ Array.isArray(windows) &&
60
+ Array.isArray(workspaces) &&
61
+ workspaces.every(
62
+ (w) =>
63
+ w &&
64
+ Number.isInteger(w.id) &&
65
+ typeof w.is_focused === "boolean" &&
66
+ (typeof w.name === "string" || Number.isInteger(w.idx)),
67
+ ) &&
68
+ new Set(workspaces.map((w) => w.id)).size === workspaces.length &&
69
+ sourceFacts !== null &&
70
+ windows.some(
71
+ (w) =>
72
+ w?.id === intent.windowId &&
73
+ w?.workspace_id === intent.workspaceId &&
74
+ w?.workspace_id === sourceFacts.workspace_id &&
75
+ w?.pid === sourceFacts.pid &&
76
+ w?.app_id === sourceFacts.app_id,
77
+ ) &&
78
+ windows.every((w) => w && Number.isInteger(w.id) && typeof w.is_focused === "boolean") &&
79
+ new Set(windows.map((w) => w.id)).size === windows.length &&
80
+ intent.matchesSource(
81
+ resolveFocusedWorkspaceView(
82
+ windows,
83
+ workspaces,
84
+ projection.getRawSessions(),
85
+ placementOptions,
86
+ ),
87
+ ),
88
+ );
89
+ };
90
+ }