@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
@@ -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,10 +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;
54
+ /** All-workspace window list. @type {import("../common/workspace-view.mjs").WindowDirectoryEntry[]} */
55
+ let directory = [];
56
+ /** @type {Map<string, SessionRecord>} */
57
+ let directoryCards = new Map();
39
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
+ }
40
125
 
41
126
  function allSessions() {
42
127
  return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
@@ -57,8 +142,7 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
57
142
  });
58
143
  }
59
144
  if (!akTasks) return cards;
60
- // Dead-session detection must consider every live session, including cards projected onto
61
- // 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.
62
146
  const liveSessionIds = new Set(
63
147
  rawSessions.map((session) => String(session.sessionId ?? "")).filter(Boolean),
64
148
  );
@@ -74,15 +158,66 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
74
158
  function currentView() {
75
159
  revision += 1;
76
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);
77
209
  return {
78
- protocol: 1,
210
+ protocol: PANEL_PROTOCOL,
79
211
  type: "view",
80
212
  revision,
81
- visible: isNiriSession() ? Boolean(workspace?.is_focused && sessions.length > 0) : true,
213
+ interactionEpoch,
214
+ visible: keepsSurface() || focusVisible,
82
215
  focusedSessionId,
83
216
  focusedCardId,
84
217
  generatedAt: snapshot.generatedAt,
85
218
  sessions,
219
+ windows: directory,
220
+ hotkey,
86
221
  };
87
222
  }
88
223
 
@@ -100,6 +235,159 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
100
235
 
101
236
  return {
102
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
+ },
103
391
  getRawSessions: allSessions,
104
392
  /** Broker-published records only, for comparisons against a broker snapshot. */
105
393
  getBrokerSessions: () => snapshot.sessions,
@@ -107,21 +395,48 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
107
395
  setAgentSessions(records) {
108
396
  agentSessions = Array.isArray(records) ? records : [];
109
397
  },
110
- /**
111
- * Replace the read-only AK task data joined onto cards. An empty refresh clears every chip;
112
- * `null` data (AK never read) leaves cards untouched, which is how the strip stays unchanged
113
- * when AK is absent.
398
+ /** Replace read-only AK chips: empty clears them; null (never read) leaves cards untouched.
114
399
  * @param {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} state
115
400
  */
116
401
  setAkTasks(state) {
117
402
  akTasks = state;
118
403
  send();
119
404
  },
120
- /** @param {string} targetId */
121
- resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
405
+ /** Resolve a current card or a cross-workspace directory entry.
406
+ * @param {string} targetId */
407
+ resolveTarget: (targetId) =>
408
+ resolveSnapshotSession(getDisplaySessions(), targetId) ??
409
+ directoryCards.get(targetId) ??
410
+ null,
411
+ /** @param {number} windowId */
412
+ hasDirectoryWindow: (windowId) => directory.some((entry) => entry.windowId === windowId),
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.
421
+ * @param {{entries: import("../common/workspace-view.mjs").WindowDirectoryEntry[]; cards: Map<string, SessionRecord>}} next
422
+ */
423
+ setDirectory(next) {
424
+ directory = next.entries;
425
+ directoryCards = next.cards;
426
+ },
122
427
  /** @param {WorkspaceView} view */
123
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
+ }
124
437
  workspace = view.workspace ?? null;
438
+ focusContextValid = Number.isInteger(workspace?.id) && workspace?.is_focused === true;
439
+ focusKind = view.focusKind ?? "unknown";
125
440
  workspaceCardIds = new Set(view.sessions.map((session) => String(session.cardId ?? "")));
126
441
  workspaceRecordKeys = new Set(
127
442
  view.sessions.flatMap((session) =>
@@ -145,6 +460,27 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
145
460
  );
146
461
  focusedSessionId = view.focusedSessionId;
147
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
+ }
148
484
  send();
149
485
  },
150
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
+ }