@tryinget/pi-activity-strip 0.4.0 → 0.6.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 (61) hide show
  1. package/README.md +136 -157
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +50 -60
  4. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/Cargo.lock +1045 -0
  7. package/native/panel/Cargo.toml +23 -0
  8. package/native/panel/src/app.rs +606 -0
  9. package/native/panel/src/card_view.rs +602 -0
  10. package/native/panel/src/main.rs +31 -0
  11. package/native/panel/src/protocol.rs +297 -0
  12. package/native/panel/src/runtime.rs +151 -0
  13. package/native/panel/src/style.css +250 -0
  14. package/package.json +16 -4
  15. package/src/broker/server.mjs +43 -7
  16. package/src/broker/session-store.mjs +41 -13
  17. package/src/client/broker-client.mjs +10 -3
  18. package/src/client/session-telemetry.mjs +54 -14
  19. package/src/common/activity-order.mjs +15 -9
  20. package/src/common/agent-identity.mjs +163 -0
  21. package/src/common/ak-tasks.mjs +354 -0
  22. package/src/common/alignment-controller.mjs +24 -42
  23. package/src/common/card-display.mjs +5 -0
  24. package/src/common/claude-events.mjs +190 -0
  25. package/src/common/claude-hook-config.mjs +56 -0
  26. package/src/common/claude-transcript.mjs +163 -0
  27. package/src/common/codex-transcript.mjs +153 -0
  28. package/src/common/compatibility.mjs +28 -13
  29. package/src/common/constants.mjs +10 -28
  30. package/src/common/contracts.d.ts +68 -4
  31. package/src/common/contracts.ts +105 -4
  32. package/src/common/ghostty-present.mjs +100 -0
  33. package/src/common/ghostty-theme.mjs +149 -0
  34. package/src/common/niri-focus.mjs +209 -140
  35. package/src/common/protocol.mjs +48 -16
  36. package/src/common/session-cards.mjs +105 -0
  37. package/src/common/status-report.mjs +53 -1
  38. package/src/common/surface-bindings.mjs +234 -0
  39. package/src/common/telemetry.mjs +8 -1
  40. package/src/common/terminal-identity.mjs +212 -0
  41. package/src/common/window-placement.mjs +59 -0
  42. package/src/common/workspace-view.mjs +161 -0
  43. package/src/native/agent-discovery.mjs +294 -0
  44. package/src/native/ak-runtime.mjs +164 -0
  45. package/src/native/codex-discovery.mjs +128 -0
  46. package/src/native/ghostty-tab-inventory.py +99 -0
  47. package/src/native/height-repair.mjs +154 -0
  48. package/src/native/main.mjs +499 -0
  49. package/src/native/panel-projection.mjs +161 -0
  50. package/src/native/placement.mjs +225 -0
  51. package/src/native/tab-inventory.mjs +188 -0
  52. package/src/native/theme-runtime.mjs +72 -0
  53. package/src/native/theme-source.mjs +197 -0
  54. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
  55. package/src/common/electron.mjs +0 -57
  56. package/src/electron/main.mjs +0 -499
  57. package/src/electron/niri-native-window-runtime.mjs +0 -178
  58. package/src/electron/preload.cjs +0 -50
  59. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  60. package/src/electron/workspace-view-runtime.mjs +0 -360
  61. package/src/ui/strip-html.mjs +0 -500
@@ -0,0 +1,499 @@
1
+ #!/usr/bin/env node
2
+ // ---
3
+ // summary: "runs the broker and workspace projection for the native GTK layer-shell panel"
4
+ // read_when:
5
+ // - "changing native activity-strip supervision or panel protocol"
6
+ // ---
7
+
8
+ import { execFile, spawn } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { promisify } from "node:util";
14
+ import { createActivityStripBroker } from "../broker/server.mjs";
15
+ import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
16
+ import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
17
+ import { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
18
+ import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
19
+ import { haveSameRecordMembership } from "../common/session-cards.mjs";
20
+ import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
21
+ import { discoverAgentTabs } from "./agent-discovery.mjs";
22
+ import { startAkTaskProjection } from "./ak-runtime.mjs";
23
+ import { createHeightRepair } from "./height-repair.mjs";
24
+ import { createNativePanelProjection } from "./panel-projection.mjs";
25
+ import { createPlacementRuntime } from "./placement.mjs";
26
+ import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
27
+ import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
28
+
29
+ /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
30
+ /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
31
+ /** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
32
+
33
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+ const packageRoot = path.resolve(__dirname, "..", "..");
35
+ const execFileAsync = promisify(execFile);
36
+
37
+ /** @type {import("../broker/server.mjs").ActivityStripBroker | null} */
38
+ let broker = null;
39
+ /** @type {PanelChild | null} */
40
+ let panel = null;
41
+ /** @type {{stop: () => void} | null} */
42
+ let watcher = null;
43
+ let panelReady = false;
44
+ let panelWriteReady = true;
45
+ /** @type {PanelMessage | null} */
46
+ let pendingView = null;
47
+ let shuttingDown = false;
48
+ let panelStderr = "";
49
+ let panelRestartCount = 0;
50
+
51
+ /** @type {ActivityStripRuntimeStatus} */
52
+ const runtimeStatus = {
53
+ state: "starting",
54
+ backend: "native-layer-shell",
55
+ displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : "unknown",
56
+ windowManager: isNiriSession() ? "niri" : null,
57
+ alignmentMode: "layer-shell",
58
+ startedAt: Date.now(),
59
+ readyAt: null,
60
+ controllerPid: process.pid,
61
+ panelPid: null,
62
+ windowVisible: false,
63
+ panelExpanded: false,
64
+ clickThrough: process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
65
+ panelMoveCount: 0,
66
+ panelActivationCount: 0,
67
+ panelRestartCount: 0,
68
+ lastMovedCardId: null,
69
+ rendererCardCount: 0,
70
+ rendererCardIds: [],
71
+ rendererHiddenTabCardCount: 0,
72
+ agentTabCount: 0,
73
+ surfaceBindingCount: 0,
74
+ unplacedSurfaceCount: 0,
75
+ tabInventoryState: process.env.PI_ACTIVITY_STRIP_TAB_INVENTORY === "0" ? "disabled" : "pending",
76
+ tabInventoryDetail: null,
77
+ tabInventoryFrameCount: 0,
78
+ tabInventoryTabCount: 0,
79
+ tabInventoryProbedAt: null,
80
+ warnings: [],
81
+ error: null,
82
+ };
83
+
84
+ function isNiriSession() {
85
+ return Boolean(process.env.NIRI_SOCKET);
86
+ }
87
+
88
+ // Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
89
+ // memory learned from visible titles and the bounded AT-SPI tab inventory.
90
+ const placement = createPlacementRuntime({
91
+ env: process.env,
92
+ runtimeStatus,
93
+ execFileAsync,
94
+ onBindingsLearned: () => reconcileRunner.request(),
95
+ });
96
+
97
+ function panelBinaryPath() {
98
+ const override = process.env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
99
+ const allowUnverified = process.env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
100
+ const candidates = [
101
+ ...(override ? [override] : []),
102
+ path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
103
+ ...(allowUnverified
104
+ ? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
105
+ : []),
106
+ ].filter(Boolean);
107
+ const binary = candidates.find((candidate) => {
108
+ try {
109
+ fs.accessSync(candidate, fs.constants.X_OK);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ });
115
+ if (!binary) {
116
+ throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
117
+ }
118
+ if (allowUnverified) return binary;
119
+ if (process.platform !== "linux" || process.arch !== "x64") {
120
+ throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
121
+ }
122
+ const artifactPath = path.join(path.dirname(binary), "artifact.json");
123
+ let artifact;
124
+ try {
125
+ artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
126
+ } catch {
127
+ throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
128
+ }
129
+ const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
130
+ if (
131
+ artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
132
+ artifact.target !== "x86_64-unknown-linux-gnu" ||
133
+ artifact.sha256 !== digest
134
+ ) {
135
+ throw new Error("Native panel binary does not match its reviewed artifact receipt.");
136
+ }
137
+ return binary;
138
+ }
139
+
140
+ /** @param {PanelMessage} message */
141
+ function writePanel(message) {
142
+ if (!panelReady || !panelWriteReady || !panel?.stdin?.writable) {
143
+ if (message?.type === "view") pendingView = message;
144
+ return false;
145
+ }
146
+ try {
147
+ const accepted = panel.stdin.write(`${JSON.stringify(message)}\n`);
148
+ if (!accepted) {
149
+ panelWriteReady = false;
150
+ const child = panel;
151
+ child.stdin.once("drain", () => {
152
+ if (panel !== child) return;
153
+ panelWriteReady = true;
154
+ if (pendingView) {
155
+ const view = pendingView;
156
+ pendingView = null;
157
+ writePanel(view);
158
+ }
159
+ });
160
+ }
161
+ return true;
162
+ } catch {
163
+ if (message?.type === "view") pendingView = message;
164
+ return false;
165
+ }
166
+ }
167
+
168
+ const projection = createNativePanelProjection({
169
+ isNiriSession,
170
+ publish(view) {
171
+ runtimeStatus.rendererCardCount = view.sessions.length;
172
+ runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
173
+ runtimeStatus.rendererHiddenTabCardCount = view.sessions.filter(
174
+ (session) => session.surfaceVisible === false,
175
+ ).length;
176
+ runtimeStatus.akTaskRenderedCount = view.sessions.reduce(
177
+ (total, session) => total + (Array.isArray(session.akTasks) ? session.akTasks.length : 0),
178
+ 0,
179
+ );
180
+ writePanel(view);
181
+ },
182
+ });
183
+
184
+ // AK task references are a read-only projection of claims held by live sessions; the strip
185
+ // never writes AK state and renders nothing when the read fails.
186
+ startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
187
+
188
+ /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
189
+ async function focusSession(targetId) {
190
+ const session = projection.resolveTarget(targetId);
191
+ if (!session) {
192
+ return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
193
+ }
194
+ const rawResult = await focusNiriSession(session, execFileAsync, process.env, placement.options);
195
+ const result = {
196
+ ok: rawResult.ok === true,
197
+ presented: rawResult.presented === true,
198
+ verified: rawResult.verified === true,
199
+ ...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
200
+ ...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
201
+ };
202
+ if (result.ok) {
203
+ projection.setFocused(session);
204
+ reconcileRunner.request();
205
+ }
206
+ return result;
207
+ }
208
+
209
+ async function focusStrip() {
210
+ if (runtimeStatus.clickThrough) {
211
+ return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
212
+ }
213
+ if (!runtimeStatus.windowVisible || runtimeStatus.rendererCardCount === 0) {
214
+ return { ok: false, error: "No visible native strip exists on this workspace." };
215
+ }
216
+ return writePanel({ type: "focus-strip" })
217
+ ? { ok: true }
218
+ : { ok: false, error: "Native panel is not ready for keyboard entry." };
219
+ }
220
+
221
+ /** @param {PanelChild} child @param {unknown} chunk */
222
+ function consumePanelEvents(child, chunk) {
223
+ if (panel !== child) return;
224
+ child._activityBuffer = `${child._activityBuffer ?? ""}${String(chunk ?? "")}`;
225
+ let newline = child._activityBuffer.indexOf("\n");
226
+ while (newline >= 0) {
227
+ const line = child._activityBuffer.slice(0, newline).trim();
228
+ child._activityBuffer = child._activityBuffer.slice(newline + 1);
229
+ if (line) {
230
+ try {
231
+ const event = JSON.parse(line);
232
+ if (event.protocol !== 1) throw new Error("unsupported native panel protocol");
233
+ if (event.type === "ready") {
234
+ clearTimeout(child._readyTimer);
235
+ panelReady = true;
236
+ runtimeStatus.state = "ready";
237
+ runtimeStatus.readyAt = Date.now();
238
+ runtimeStatus.panelPid = Number(event.pid) || child.pid || null;
239
+ runtimeStatus.error = null;
240
+ clearTimeout(child._stableTimer);
241
+ child._stableTimer = setTimeout(() => {
242
+ if (panel === child && panelReady) {
243
+ panelRestartCount = 0;
244
+ runtimeStatus.panelRestartCount = 0;
245
+ }
246
+ }, 30_000);
247
+ child._stableTimer.unref?.();
248
+ theme.republish();
249
+ if (pendingView) {
250
+ const view = pendingView;
251
+ pendingView = null;
252
+ writePanel(view);
253
+ } else {
254
+ projection.send();
255
+ }
256
+ } else if (event.type === "visibility-applied") {
257
+ const visible = event.visible === true;
258
+ const changed = runtimeStatus.windowVisible !== visible;
259
+ runtimeStatus.windowVisible = visible;
260
+ // Showing or hiding the surface is the only moment the exclusive zone changes, so it is
261
+ // the only moment a tiled window can be left at the previous working area's height.
262
+ if (changed) void heightRepair.repairAfterZoneChange();
263
+ } else if (event.type === "expanded") {
264
+ runtimeStatus.panelExpanded = event.expanded === true;
265
+ } else if (event.type === "activate") {
266
+ runtimeStatus.panelActivationCount = (runtimeStatus.panelActivationCount ?? 0) + 1;
267
+ const cardId = String(event.cardId ?? "");
268
+ void focusSession(cardId).then((result) =>
269
+ writePanel({
270
+ type: "activation-result",
271
+ cardId,
272
+ ok: result.ok === true,
273
+ message: result.ok
274
+ ? result.presented
275
+ ? result.verified
276
+ ? "Presented the hidden tab and focused its Ghostty window."
277
+ : "Focused its Ghostty window and asked Ghostty to show the tab."
278
+ : "Focused Ghostty window."
279
+ : result.error || "Focus failed; nothing moved.",
280
+ }),
281
+ );
282
+ } else if (event.type === "moved") {
283
+ runtimeStatus.panelMoveCount = (runtimeStatus.panelMoveCount ?? 0) + 1;
284
+ runtimeStatus.lastMovedCardId = String(event.cardId ?? "") || null;
285
+ } else if (event.type === "error") {
286
+ runtimeStatus.warnings = [String(event.message ?? "Native panel reported an error.")];
287
+ }
288
+ } catch (error) {
289
+ runtimeStatus.warnings = [
290
+ `Ignored malformed native panel event: ${error instanceof Error ? error.message : String(error)}`,
291
+ ];
292
+ }
293
+ }
294
+ newline = child._activityBuffer.indexOf("\n");
295
+ }
296
+ }
297
+
298
+ function startPanel() {
299
+ if (shuttingDown) return;
300
+ const binary = panelBinaryPath();
301
+ /** @type {PanelChild} */
302
+ const child = spawn(binary, [], {
303
+ env: process.env,
304
+ stdio: ["pipe", "pipe", "pipe"],
305
+ });
306
+ panel = child;
307
+ panelReady = false;
308
+ panelWriteReady = true;
309
+ panelStderr = "";
310
+ runtimeStatus.panelPid = child.pid ?? null;
311
+ runtimeStatus.state = "starting";
312
+ child.stdout.setEncoding("utf8");
313
+ child.stdin.on("error", () => {
314
+ // Exit supervision below owns recovery from a closed panel pipe.
315
+ });
316
+ child.stderr.setEncoding("utf8");
317
+ child.stdout.on("data", (chunk) => consumePanelEvents(child, chunk));
318
+ child.stderr.on("data", (chunk) => {
319
+ if (panel === child) panelStderr = `${panelStderr}${chunk}`.slice(-4000);
320
+ });
321
+ child._readyTimer = setTimeout(() => {
322
+ if (panel === child && !panelReady) child.kill("SIGKILL");
323
+ }, 5000);
324
+ child._readyTimer.unref?.();
325
+ child.on("error", (error) => {
326
+ if (panel === child) runtimeStatus.error = error.message;
327
+ });
328
+ child.on("exit", (code, signal) => {
329
+ clearTimeout(child._readyTimer);
330
+ clearTimeout(child._stableTimer);
331
+ if (panel !== child) return;
332
+ panelReady = false;
333
+ runtimeStatus.windowVisible = false;
334
+ runtimeStatus.panelPid = null;
335
+ if (shuttingDown) return;
336
+ if (panelRestartCount < 3) {
337
+ panelRestartCount += 1;
338
+ runtimeStatus.panelRestartCount = panelRestartCount;
339
+ runtimeStatus.state = "starting";
340
+ runtimeStatus.error = null;
341
+ const timer = setTimeout(startPanel, 250 * 2 ** (panelRestartCount - 1));
342
+ timer.unref?.();
343
+ return;
344
+ }
345
+ runtimeStatus.state = "error";
346
+ runtimeStatus.error =
347
+ `Native panel exited (${signal ?? code ?? "unknown"}). ${panelStderr}`.trim();
348
+ });
349
+ }
350
+
351
+ const getNiriWindows = () =>
352
+ readNiriWindows(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
353
+ const getNiriWorkspaces = () =>
354
+ readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
355
+
356
+ // Agent tabs are read from the process table, not the broker, so they are refreshed on a calm
357
+ // clock of their own rather than on every workspace event.
358
+ const AGENT_SCAN_INTERVAL_MS = 3000;
359
+ let agentScanAt = 0;
360
+
361
+ function refreshAgentTabs() {
362
+ if (process.env.PI_ACTIVITY_STRIP_AGENT_TABS === "0") return;
363
+ if (Date.now() - agentScanAt < AGENT_SCAN_INTERVAL_MS) return;
364
+ agentScanAt = Date.now();
365
+ try {
366
+ const records = discoverAgentTabs({ env: process.env });
367
+ runtimeStatus.agentTabCount = records.length;
368
+ runtimeStatus.agentScanError = null;
369
+ projection.setAgentSessions(records);
370
+ // A session that dies without firing its end hook leaves a record behind; retire the ones no
371
+ // live tab claims so they cannot accumulate.
372
+ pruneClaudeEventRecords(
373
+ records
374
+ .filter((record) => record.agentKind === "claude" && record.agentSessionKey)
375
+ .map((record) => String(record.agentSessionKey)),
376
+ );
377
+ } catch (error) {
378
+ // Recorded on its own field: replacing the shared warning list would erase a panel error, and
379
+ // a transient scan failure must not leave a permanent warning behind.
380
+ runtimeStatus.agentScanError = error instanceof Error ? error.message : String(error);
381
+ }
382
+ }
383
+
384
+ // The ribbon reads Ghostty's own theme, so it follows the terminal into light and dark.
385
+ const theme = createThemeRuntime({
386
+ execFileAsync,
387
+ env: process.env,
388
+ runtimeStatus,
389
+ publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
390
+ });
391
+
392
+ const heightRepair = createHeightRepair({
393
+ execFileAsync,
394
+ env: process.env,
395
+ runtimeStatus,
396
+ readWindows: getNiriWindows,
397
+ });
398
+
399
+ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
400
+ if (!isNiriSession()) {
401
+ projection.send();
402
+ return;
403
+ }
404
+ const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
405
+ if (!isCurrent()) return;
406
+ placement.observeWindowList(windows);
407
+ heightRepair.observe(windows);
408
+ refreshAgentTabs();
409
+ runtimeStatus.surfaceBindingCount = placement.bindingCount;
410
+ const sessions = projection.getRawSessions();
411
+ runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
412
+ const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
413
+ if (view) placement.learnFromPlacements(view.sessions, windows);
414
+ projection.publishWorkspaceView(
415
+ view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
416
+ );
417
+ void placement.scheduleInventory(windows);
418
+ });
419
+
420
+ async function shutdown(exitCode = 0) {
421
+ if (shuttingDown) return;
422
+ shuttingDown = true;
423
+ watcher?.stop();
424
+ placement.persist();
425
+ writePanel({
426
+ protocol: 1,
427
+ type: "view",
428
+ revision: Number.MAX_SAFE_INTEGER,
429
+ visible: false,
430
+ sessions: [],
431
+ });
432
+ panel?.stdin?.end();
433
+ await new Promise((resolve) => setTimeout(resolve, 80));
434
+ panel?.kill("SIGTERM");
435
+ await broker?.stop();
436
+ process.exitCode = exitCode;
437
+ }
438
+
439
+ async function main() {
440
+ if (process.env.PI_ACTIVITY_STRIP_RUNTIME_LOCK_HELD !== "1") {
441
+ throw new Error("Native runtime must be launched through the flock-guarded CLI.");
442
+ }
443
+ broker = await createActivityStripBroker({
444
+ focusSession,
445
+ focusStrip,
446
+ getRuntimeStatus: () => ({
447
+ ...runtimeStatus,
448
+ warnings: [...(runtimeStatus.warnings ?? [])],
449
+ }),
450
+ });
451
+ broker.on("snapshot", (snapshot) => {
452
+ // Compare like with like: the raw set also holds discovered agent tabs, which the broker
453
+ // never publishes, so comparing it to a broker snapshot would always differ and reconcile.
454
+ const membershipChanged = !haveSameRecordMembership(
455
+ projection.getBrokerSessions(),
456
+ snapshot.sessions,
457
+ );
458
+ projection.updateSnapshot(snapshot);
459
+ if (isNiriSession() && membershipChanged) reconcileRunner.request();
460
+ });
461
+ broker.on("shutdown-requested", () => void shutdown(0));
462
+
463
+ startPanel();
464
+ if (isNiriSession()) {
465
+ reconcileRunner.request();
466
+ watcher = createNiriWorkspaceEventWatcher({
467
+ spawn,
468
+ env: process.env,
469
+ onFocusedWorkspace: () => reconcileRunner.request(),
470
+ onFallback: () => reconcileRunner.request(),
471
+ onWindowChanged: (window) => {
472
+ if (placement.observeWindowEvent(window)) reconcileRunner.request();
473
+ },
474
+ onWindowClosed: (windowId) => {
475
+ if (placement.forgetWindow(windowId)) reconcileRunner.request();
476
+ },
477
+ onWindowFocusChanged: () => reconcileRunner.request(),
478
+ fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
479
+ });
480
+ }
481
+
482
+ void theme.refresh({ force: true });
483
+ const themeTimer = setInterval(() => void theme.refresh(), THEME_POLL_INTERVAL_MS);
484
+ themeTimer.unref?.();
485
+
486
+ process.once("SIGINT", () => void shutdown(0));
487
+ process.once("SIGTERM", () => void shutdown(0));
488
+ }
489
+
490
+ main().catch(async (error) => {
491
+ runtimeStatus.state = "error";
492
+ runtimeStatus.error = error instanceof Error ? error.message : String(error);
493
+ console.error(error?.stack ?? error);
494
+ try {
495
+ await shutdown(1);
496
+ } catch {
497
+ process.exitCode = 1;
498
+ }
499
+ });
@@ -0,0 +1,161 @@
1
+ // ---
2
+ // summary: "projects broker snapshots into presentation-ready native-panel views"
3
+ // read_when:
4
+ // - "changing native panel card membership, focus, or transport revisions"
5
+ // ---
6
+
7
+ import { joinAkTaskChips } from "../common/ak-tasks.mjs";
8
+ import { resolveSnapshotSession } from "../common/niri-focus.mjs";
9
+ import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
10
+
11
+ /** @typedef {Record<string, unknown>} SessionRecord */
12
+ /** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
13
+ /** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
14
+ /** @typedef {{placement: string; surfaceVisible: boolean; windowId: number | null}} CardPlacement */
15
+ /** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
16
+
17
+ /** @param {NativePanelProjectionOptions} options */
18
+ export function createNativePanelProjection({ isNiriSession, publish }) {
19
+ /** @type {Snapshot} */
20
+ let snapshot = { generatedAt: Date.now(), sessions: [] };
21
+ /** Agent tabs discovered from the process table; they publish no telemetry of their own. */
22
+ /** @type {SessionRecord[]} */
23
+ let agentSessions = [];
24
+ /** @type {string | null} */
25
+ let focusedSessionId = null;
26
+ /** @type {string | null} */
27
+ let focusedCardId = null;
28
+ /** @type {Record<string, unknown> | null} */
29
+ let workspace = null;
30
+ /** @type {Set<string>} */
31
+ let workspaceCardIds = new Set();
32
+ /** @type {Set<string>} */
33
+ let workspaceRecordKeys = new Set();
34
+ /** @type {Map<string, CardPlacement>} */
35
+ 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} */
38
+ let akTasks = null;
39
+ let revision = 0;
40
+
41
+ function allSessions() {
42
+ return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
43
+ }
44
+
45
+ function getDisplaySessions() {
46
+ const rawSessions = allSessions();
47
+ let cards;
48
+ if (!isNiriSession()) {
49
+ cards = projectSessionCards(rawSessions, null);
50
+ } else {
51
+ const sessions = rawSessions.filter((session) =>
52
+ workspaceRecordKeys.has(sessionRecordKey(session)),
53
+ );
54
+ cards = projectSessionCards(sessions, workspaceCardIds).map((card) => {
55
+ const placement = workspacePlacements.get(card.cardId);
56
+ return placement ? { ...card, ...placement } : card;
57
+ });
58
+ }
59
+ 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.
62
+ const liveSessionIds = new Set(
63
+ rawSessions.map((session) => String(session.sessionId ?? "")).filter(Boolean),
64
+ );
65
+ return joinAkTaskChips({
66
+ cards,
67
+ claims: akTasks.claims,
68
+ deferred: akTasks.deferred,
69
+ liveSessionIds,
70
+ nowMs: Date.now(),
71
+ }).cards;
72
+ }
73
+
74
+ function currentView() {
75
+ revision += 1;
76
+ const sessions = getDisplaySessions();
77
+ return {
78
+ protocol: 1,
79
+ type: "view",
80
+ revision,
81
+ visible: isNiriSession() ? Boolean(workspace?.is_focused && sessions.length > 0) : true,
82
+ focusedSessionId,
83
+ focusedCardId,
84
+ generatedAt: snapshot.generatedAt,
85
+ sessions,
86
+ };
87
+ }
88
+
89
+ function send() {
90
+ publish(currentView());
91
+ }
92
+
93
+ function retainValidFocus() {
94
+ const targetId = focusedCardId ?? focusedSessionId;
95
+ if (!targetId || !resolveSnapshotSession(getDisplaySessions(), targetId)) {
96
+ focusedSessionId = null;
97
+ focusedCardId = null;
98
+ }
99
+ }
100
+
101
+ return {
102
+ getDisplaySessions,
103
+ getRawSessions: allSessions,
104
+ /** Broker-published records only, for comparisons against a broker snapshot. */
105
+ getBrokerSessions: () => snapshot.sessions,
106
+ /** @param {SessionRecord[]} records */
107
+ setAgentSessions(records) {
108
+ agentSessions = Array.isArray(records) ? records : [];
109
+ },
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.
114
+ * @param {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} state
115
+ */
116
+ setAkTasks(state) {
117
+ akTasks = state;
118
+ send();
119
+ },
120
+ /** @param {string} targetId */
121
+ resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
122
+ /** @param {WorkspaceView} view */
123
+ publishWorkspaceView(view) {
124
+ workspace = view.workspace ?? null;
125
+ workspaceCardIds = new Set(view.sessions.map((session) => String(session.cardId ?? "")));
126
+ workspaceRecordKeys = new Set(
127
+ view.sessions.flatMap((session) =>
128
+ Array.isArray(session.publisherRecordKeys)
129
+ ? session.publisherRecordKeys.map(String)
130
+ : [sessionRecordKey(session)],
131
+ ),
132
+ );
133
+ workspacePlacements = new Map(
134
+ view.sessions.map((session) => [
135
+ String(session.cardId ?? ""),
136
+ {
137
+ placement: String(session.placement ?? "title"),
138
+ surfaceVisible: session.surfaceVisible !== false,
139
+ windowId: Number.isInteger(session.windowId) ? Number(session.windowId) : null,
140
+ },
141
+ ]),
142
+ );
143
+ focusedSessionId = view.focusedSessionId;
144
+ focusedCardId = view.focusedCardId ?? null;
145
+ send();
146
+ },
147
+ send,
148
+ /** @param {SessionRecord} session */
149
+ setFocused(session) {
150
+ focusedSessionId = String(session.sessionId ?? "") || null;
151
+ focusedCardId = String(session.cardId ?? "") || null;
152
+ send();
153
+ },
154
+ /** @param {Snapshot} nextSnapshot */
155
+ updateSnapshot(nextSnapshot) {
156
+ snapshot = nextSnapshot;
157
+ retainValidFocus();
158
+ send();
159
+ },
160
+ };
161
+ }