@tryinget/pi-activity-strip 0.3.0 → 0.5.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 (42) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/examples/simulate-sessions.mjs +1 -1
  4. package/extensions/activity-strip.js +2 -2
  5. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/Cargo.lock +1045 -0
  8. package/native/panel/Cargo.toml +23 -0
  9. package/native/panel/src/app.rs +606 -0
  10. package/native/panel/src/card_view.rs +303 -0
  11. package/native/panel/src/main.rs +31 -0
  12. package/native/panel/src/protocol.rs +234 -0
  13. package/native/panel/src/runtime.rs +110 -0
  14. package/native/panel/src/style.css +176 -0
  15. package/package.json +20 -7
  16. package/src/broker/server.mjs +43 -7
  17. package/src/broker/session-store.mjs +53 -11
  18. package/src/client/broker-client.mjs +25 -4
  19. package/src/client/session-telemetry.mjs +123 -13
  20. package/src/common/activity-order.mjs +15 -9
  21. package/src/common/alignment-controller.mjs +24 -42
  22. package/src/common/card-display.mjs +53 -0
  23. package/src/common/compatibility.mjs +15 -13
  24. package/src/common/constants.mjs +12 -28
  25. package/src/common/contracts.d.ts +54 -5
  26. package/src/common/contracts.ts +54 -5
  27. package/src/common/niri-focus.mjs +79 -79
  28. package/src/common/protocol.mjs +49 -14
  29. package/src/common/session-cards.mjs +102 -0
  30. package/src/common/status-report.mjs +2 -1
  31. package/src/common/telemetry.mjs +11 -1
  32. package/src/common/terminal-identity.mjs +149 -0
  33. package/src/native/main.mjs +393 -0
  34. package/src/native/panel-projection.mjs +99 -0
  35. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  36. package/src/common/electron.mjs +0 -57
  37. package/src/electron/main.mjs +0 -499
  38. package/src/electron/niri-native-window-runtime.mjs +0 -178
  39. package/src/electron/preload.cjs +0 -50
  40. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  41. package/src/electron/workspace-view-runtime.mjs +0 -360
  42. package/src/ui/strip-html.mjs +0 -484
@@ -14,6 +14,7 @@ export function formatBrokerRuntimeStatus(result) {
14
14
 
15
15
  const lines = [
16
16
  runtime.state === "ready" ? "running (ready)" : `running (${runtime.state})`,
17
+ `backend: ${runtime.backend || "unknown"}`,
17
18
  `display: ${runtime.displayServer || "unknown"}`,
18
19
  `window manager: ${runtime.windowManager || "unknown"}`,
19
20
  `alignment: ${runtime.alignmentMode || "unknown"}`,
@@ -23,7 +24,7 @@ export function formatBrokerRuntimeStatus(result) {
23
24
  lines.push(`displays: ${runtime.displayCount}`);
24
25
  }
25
26
  if (typeof runtime.windowVisible === "boolean") {
26
- lines.push(`window visible: ${runtime.windowVisible ? "yes" : "no"}`);
27
+ lines.push(`surface visible: ${runtime.windowVisible ? "yes" : "no"}`);
27
28
  }
28
29
  if (runtime.error) {
29
30
  lines.push(`error: ${runtime.error}`);
@@ -67,16 +67,25 @@ export function createSessionId() {
67
67
  return `${os.hostname()}-${process.pid}-${Date.now()}-${randomUUID().slice(0, 6)}`;
68
68
  }
69
69
 
70
- /** @param {{ cwd?: string; sessionName?: string; sessionId?: string }} [options] @returns {SessionSnapshot} */
70
+ /** @param {{ cwd?: string; sessionName?: string; sessionId?: string; publisherId?: string; terminalIdentity?: {terminalKind?: string; terminalKey?: string; terminalFamily?: string; terminalSurfaceId?: string} }} [options] @returns {SessionSnapshot} */
71
71
  export function createInitialSnapshot({
72
72
  cwd = process.cwd(),
73
73
  sessionName = "",
74
74
  sessionId = "",
75
+ publisherId = "",
76
+ terminalIdentity = {},
75
77
  } = {}) {
76
78
  const now = Date.now();
77
79
  return {
78
80
  sessionId: sessionId.trim() || createSessionId(),
81
+ publisherId: publisherId.trim() || randomUUID(),
82
+ publisherSequence: 0,
79
83
  processId: process.pid,
84
+ terminalKind:
85
+ terminalIdentity.terminalKind === "ghostty-surface" ? "ghostty-surface" : "unbound",
86
+ terminalKey: String(terminalIdentity.terminalKey ?? ""),
87
+ terminalFamily: String(terminalIdentity.terminalFamily ?? ""),
88
+ terminalSurfaceId: String(terminalIdentity.terminalSurfaceId ?? ""),
80
89
  cwd,
81
90
  repoLabel: formatRepoLabel(cwd, sessionName),
82
91
  sessionName: compactWhitespace(sessionName),
@@ -88,6 +97,7 @@ export function createInitialSnapshot({
88
97
  state: "idle",
89
98
  turnIndex: 0,
90
99
  updatedAt: now,
100
+ lastEventAt: now,
91
101
  startedAt: now,
92
102
  agentStartedAt: null,
93
103
  agentActive: false,
@@ -0,0 +1,149 @@
1
+ // ---
2
+ // summary: "derives bounded terminal-surface identities for activity-strip publisher and focus contracts"
3
+ // read_when:
4
+ // - "changing terminal card identity, Ghostty surface admission, or title matching"
5
+ // ---
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const UINT64_MAX = 18_446_744_073_709_551_615n;
11
+ const GHOSTTY_MAIN_APP_ID = "com.mitchellh.ghostty";
12
+ const GHOSTTY_LEGACY_APP_ID = "com.tryinget.ghosttysidequest";
13
+
14
+ /** @typedef {{terminalKind: "ghostty-surface" | "unbound"; terminalKey: string; terminalFamily: string; terminalSurfaceId: string}} TerminalIdentity */
15
+ /** @typedef {{env?: NodeJS.ProcessEnv; hasUI?: boolean; stdinIsTTY?: boolean; ttyPath?: string; processId?: number; ancestorExecutable?: string}} TerminalIdentityOptions */
16
+
17
+ /** @param {string} filePath */
18
+ function safeReadFile(filePath) {
19
+ try {
20
+ return fs.readFileSync(filePath, "utf8");
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ /** @param {string} filePath */
27
+ function safeReadLink(filePath) {
28
+ try {
29
+ return fs.readlinkSync(filePath);
30
+ } catch {
31
+ return "";
32
+ }
33
+ }
34
+
35
+ /** @param {number} pid */
36
+ function readParentPid(pid) {
37
+ const value = safeReadFile(path.join("/proc", String(pid), "stat"));
38
+ const lastParen = value.lastIndexOf(")");
39
+ if (lastParen < 0) return 0;
40
+ const fields = value
41
+ .slice(lastParen + 2)
42
+ .trim()
43
+ .split(/\s+/);
44
+ const parentPid = Number.parseInt(fields[1] ?? "", 10);
45
+ return Number.isInteger(parentPid) && parentPid > 0 ? parentPid : 0;
46
+ }
47
+
48
+ /** @param {unknown} value */
49
+ export function normalizeGhosttySurfaceId(value) {
50
+ try {
51
+ const normalized = BigInt(String(value ?? "").trim());
52
+ return normalized >= 0n && normalized <= UINT64_MAX ? normalized.toString(10) : "";
53
+ } catch {
54
+ return "";
55
+ }
56
+ }
57
+
58
+ /** @param {unknown} executable */
59
+ export function classifyGhosttyFamily(executable) {
60
+ const normalized = String(executable ?? "").toLowerCase();
61
+ if (!normalized) return "";
62
+ if (normalized.includes("ghostty-sidequest")) return "legacy";
63
+ if (path.basename(normalized) === "ghostty" || normalized.includes("ghostty-origin"))
64
+ return "main";
65
+ return "";
66
+ }
67
+
68
+ /** @param {unknown} family */
69
+ export function appIdForGhosttyFamily(family) {
70
+ if (family === "main") return GHOSTTY_MAIN_APP_ID;
71
+ if (family === "legacy") return GHOSTTY_LEGACY_APP_ID;
72
+ return "";
73
+ }
74
+
75
+ /** @param {number} [processId] */
76
+ export function findGhosttyAncestorExecutable(processId = process.pid) {
77
+ let pid = Number(processId) || 0;
78
+ for (let depth = 0; depth < 12 && pid > 0; depth += 1) {
79
+ pid = readParentPid(pid);
80
+ if (pid <= 0) break;
81
+ const command = safeReadFile(path.join("/proc", String(pid), "comm"))
82
+ .trim()
83
+ .toLowerCase();
84
+ if (command === "ghostty") return safeReadLink(path.join("/proc", String(pid), "exe"));
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /** @returns {TerminalIdentity} */
90
+ export function unboundTerminalIdentity() {
91
+ return {
92
+ terminalKind: "unbound",
93
+ terminalKey: "",
94
+ terminalFamily: "",
95
+ terminalSurfaceId: "",
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Admit a terminal binding only for an interactive TUI attached to a real TTY and a recognized
101
+ * Ghostty process family. Headless descendants commonly inherit Ghostty environment variables, so
102
+ * GHOSTTY_SURFACE_ID alone is not evidence of an operator-visible terminal.
103
+ * @param {TerminalIdentityOptions} [options]
104
+ * @returns {TerminalIdentity}
105
+ */
106
+ export function resolveTerminalIdentity({
107
+ env = process.env,
108
+ hasUI = false,
109
+ stdinIsTTY = Boolean(process.stdin.isTTY),
110
+ ttyPath = safeReadLink("/proc/self/fd/0"),
111
+ processId = process.pid,
112
+ ancestorExecutable,
113
+ } = {}) {
114
+ if (
115
+ !hasUI ||
116
+ !stdinIsTTY ||
117
+ !ttyPath.startsWith("/dev/") ||
118
+ String(env.TERM_PROGRAM ?? "").toLowerCase() !== "ghostty"
119
+ ) {
120
+ return unboundTerminalIdentity();
121
+ }
122
+ const surfaceId = normalizeGhosttySurfaceId(env.GHOSTTY_SURFACE_ID);
123
+ const executable = ancestorExecutable ?? findGhosttyAncestorExecutable(processId);
124
+ const family = classifyGhosttyFamily(executable);
125
+ if (!surfaceId || !family) return unboundTerminalIdentity();
126
+ return {
127
+ terminalKind: "ghostty-surface",
128
+ terminalKey: `ghostty:${family}:${surfaceId}`,
129
+ terminalFamily: family,
130
+ terminalSurfaceId: surfaceId,
131
+ };
132
+ }
133
+
134
+ /** @param {Partial<TerminalIdentity> | Record<string, unknown>} identity */
135
+ export function canonicalGhosttyTerminalKey(identity) {
136
+ if (identity?.terminalKind !== "ghostty-surface") return "";
137
+ const family = String(identity.terminalFamily ?? "");
138
+ if (family !== "main" && family !== "legacy") return "";
139
+ const surfaceId = normalizeGhosttySurfaceId(identity.terminalSurfaceId);
140
+ if (!surfaceId) return "";
141
+ const expectedKey = `ghostty:${family}:${surfaceId}`;
142
+ return String(identity.terminalKey ?? "") === expectedKey ? expectedKey : "";
143
+ }
144
+
145
+ /** @param {Partial<TerminalIdentity> | Record<string, unknown>} identity */
146
+ export function terminalTitleSegment(identity) {
147
+ if (!canonicalGhosttyTerminalKey(identity)) return "";
148
+ return `gs:${identity.terminalFamily}:${normalizeGhosttySurfaceId(identity.terminalSurfaceId)}`;
149
+ }
@@ -0,0 +1,393 @@
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 { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
17
+ import {
18
+ focusNiriSession,
19
+ readNiriWindows,
20
+ readNiriWorkspaces,
21
+ resolveFocusedWorkspaceView,
22
+ } from "../common/niri-focus.mjs";
23
+ import { haveSameRecordMembership } from "../common/session-cards.mjs";
24
+ import { createNativePanelProjection } from "./panel-projection.mjs";
25
+ import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
26
+
27
+ /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
28
+ /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
29
+ /** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const packageRoot = path.resolve(__dirname, "..", "..");
33
+ const execFileAsync = promisify(execFile);
34
+
35
+ /** @type {import("../broker/server.mjs").ActivityStripBroker | null} */
36
+ let broker = null;
37
+ /** @type {PanelChild | null} */
38
+ let panel = null;
39
+ /** @type {{stop: () => void} | null} */
40
+ let watcher = null;
41
+ let panelReady = false;
42
+ let panelWriteReady = true;
43
+ /** @type {PanelMessage | null} */
44
+ let pendingView = null;
45
+ let shuttingDown = false;
46
+ let panelStderr = "";
47
+ let panelRestartCount = 0;
48
+
49
+ /** @type {ActivityStripRuntimeStatus} */
50
+ const runtimeStatus = {
51
+ state: "starting",
52
+ backend: "native-layer-shell",
53
+ displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : "unknown",
54
+ windowManager: isNiriSession() ? "niri" : null,
55
+ alignmentMode: "layer-shell",
56
+ startedAt: Date.now(),
57
+ readyAt: null,
58
+ controllerPid: process.pid,
59
+ panelPid: null,
60
+ windowVisible: false,
61
+ panelExpanded: false,
62
+ clickThrough: process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
63
+ panelMoveCount: 0,
64
+ panelActivationCount: 0,
65
+ panelRestartCount: 0,
66
+ lastMovedCardId: null,
67
+ rendererCardCount: 0,
68
+ rendererCardIds: [],
69
+ warnings: [],
70
+ error: null,
71
+ };
72
+
73
+ function isNiriSession() {
74
+ return Boolean(process.env.NIRI_SOCKET);
75
+ }
76
+
77
+ function panelBinaryPath() {
78
+ const override = process.env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
79
+ const allowUnverified = process.env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
80
+ const candidates = [
81
+ ...(override ? [override] : []),
82
+ path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
83
+ ...(allowUnverified
84
+ ? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
85
+ : []),
86
+ ].filter(Boolean);
87
+ const binary = candidates.find((candidate) => {
88
+ try {
89
+ fs.accessSync(candidate, fs.constants.X_OK);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ });
95
+ if (!binary) {
96
+ throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
97
+ }
98
+ if (allowUnverified) return binary;
99
+ if (process.platform !== "linux" || process.arch !== "x64") {
100
+ throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
101
+ }
102
+ const artifactPath = path.join(path.dirname(binary), "artifact.json");
103
+ let artifact;
104
+ try {
105
+ artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
106
+ } catch {
107
+ throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
108
+ }
109
+ const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
110
+ if (
111
+ artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
112
+ artifact.target !== "x86_64-unknown-linux-gnu" ||
113
+ artifact.sha256 !== digest
114
+ ) {
115
+ throw new Error("Native panel binary does not match its reviewed artifact receipt.");
116
+ }
117
+ return binary;
118
+ }
119
+
120
+ /** @param {PanelMessage} message */
121
+ function writePanel(message) {
122
+ if (!panelReady || !panelWriteReady || !panel?.stdin?.writable) {
123
+ if (message?.type === "view") pendingView = message;
124
+ return false;
125
+ }
126
+ try {
127
+ const accepted = panel.stdin.write(`${JSON.stringify(message)}\n`);
128
+ if (!accepted) {
129
+ panelWriteReady = false;
130
+ const child = panel;
131
+ child.stdin.once("drain", () => {
132
+ if (panel !== child) return;
133
+ panelWriteReady = true;
134
+ if (pendingView) {
135
+ const view = pendingView;
136
+ pendingView = null;
137
+ writePanel(view);
138
+ }
139
+ });
140
+ }
141
+ return true;
142
+ } catch {
143
+ if (message?.type === "view") pendingView = message;
144
+ return false;
145
+ }
146
+ }
147
+
148
+ const projection = createNativePanelProjection({
149
+ isNiriSession,
150
+ publish(view) {
151
+ runtimeStatus.rendererCardCount = view.sessions.length;
152
+ runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
153
+ writePanel(view);
154
+ },
155
+ });
156
+
157
+ /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number}>} */
158
+ async function focusSession(targetId) {
159
+ const session = projection.resolveTarget(targetId);
160
+ if (!session) {
161
+ return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
162
+ }
163
+ const rawResult = await focusNiriSession(session, execFileAsync, process.env);
164
+ const result = {
165
+ ok: rawResult.ok === true,
166
+ ...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
167
+ ...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
168
+ };
169
+ if (result.ok) {
170
+ projection.setFocused(session);
171
+ reconcileRunner.request();
172
+ }
173
+ return result;
174
+ }
175
+
176
+ async function focusStrip() {
177
+ if (runtimeStatus.clickThrough) {
178
+ return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
179
+ }
180
+ if (!runtimeStatus.windowVisible || runtimeStatus.rendererCardCount === 0) {
181
+ return { ok: false, error: "No visible native strip exists on this workspace." };
182
+ }
183
+ return writePanel({ type: "focus-strip" })
184
+ ? { ok: true }
185
+ : { ok: false, error: "Native panel is not ready for keyboard entry." };
186
+ }
187
+
188
+ /** @param {PanelChild} child @param {unknown} chunk */
189
+ function consumePanelEvents(child, chunk) {
190
+ if (panel !== child) return;
191
+ child._activityBuffer = `${child._activityBuffer ?? ""}${String(chunk ?? "")}`;
192
+ let newline = child._activityBuffer.indexOf("\n");
193
+ while (newline >= 0) {
194
+ const line = child._activityBuffer.slice(0, newline).trim();
195
+ child._activityBuffer = child._activityBuffer.slice(newline + 1);
196
+ if (line) {
197
+ try {
198
+ const event = JSON.parse(line);
199
+ if (event.protocol !== 1) throw new Error("unsupported native panel protocol");
200
+ if (event.type === "ready") {
201
+ clearTimeout(child._readyTimer);
202
+ panelReady = true;
203
+ runtimeStatus.state = "ready";
204
+ runtimeStatus.readyAt = Date.now();
205
+ runtimeStatus.panelPid = Number(event.pid) || child.pid || null;
206
+ runtimeStatus.error = null;
207
+ clearTimeout(child._stableTimer);
208
+ child._stableTimer = setTimeout(() => {
209
+ if (panel === child && panelReady) {
210
+ panelRestartCount = 0;
211
+ runtimeStatus.panelRestartCount = 0;
212
+ }
213
+ }, 30_000);
214
+ child._stableTimer.unref?.();
215
+ if (pendingView) {
216
+ const view = pendingView;
217
+ pendingView = null;
218
+ writePanel(view);
219
+ } else {
220
+ projection.send();
221
+ }
222
+ } else if (event.type === "visibility-applied") {
223
+ runtimeStatus.windowVisible = event.visible === true;
224
+ } else if (event.type === "expanded") {
225
+ runtimeStatus.panelExpanded = event.expanded === true;
226
+ } else if (event.type === "activate") {
227
+ runtimeStatus.panelActivationCount = (runtimeStatus.panelActivationCount ?? 0) + 1;
228
+ const cardId = String(event.cardId ?? "");
229
+ void focusSession(cardId).then((result) =>
230
+ writePanel({
231
+ type: "activation-result",
232
+ cardId,
233
+ ok: result.ok === true,
234
+ message: result.ok
235
+ ? "Focused Ghostty window."
236
+ : result.error || "Focus failed; nothing moved.",
237
+ }),
238
+ );
239
+ } else if (event.type === "moved") {
240
+ runtimeStatus.panelMoveCount = (runtimeStatus.panelMoveCount ?? 0) + 1;
241
+ runtimeStatus.lastMovedCardId = String(event.cardId ?? "") || null;
242
+ } else if (event.type === "error") {
243
+ runtimeStatus.warnings = [String(event.message ?? "Native panel reported an error.")];
244
+ }
245
+ } catch (error) {
246
+ runtimeStatus.warnings = [
247
+ `Ignored malformed native panel event: ${error instanceof Error ? error.message : String(error)}`,
248
+ ];
249
+ }
250
+ }
251
+ newline = child._activityBuffer.indexOf("\n");
252
+ }
253
+ }
254
+
255
+ function startPanel() {
256
+ if (shuttingDown) return;
257
+ const binary = panelBinaryPath();
258
+ /** @type {PanelChild} */
259
+ const child = spawn(binary, [], {
260
+ env: process.env,
261
+ stdio: ["pipe", "pipe", "pipe"],
262
+ });
263
+ panel = child;
264
+ panelReady = false;
265
+ panelWriteReady = true;
266
+ panelStderr = "";
267
+ runtimeStatus.panelPid = child.pid ?? null;
268
+ runtimeStatus.state = "starting";
269
+ child.stdout.setEncoding("utf8");
270
+ child.stdin.on("error", () => {
271
+ // Exit supervision below owns recovery from a closed panel pipe.
272
+ });
273
+ child.stderr.setEncoding("utf8");
274
+ child.stdout.on("data", (chunk) => consumePanelEvents(child, chunk));
275
+ child.stderr.on("data", (chunk) => {
276
+ if (panel === child) panelStderr = `${panelStderr}${chunk}`.slice(-4000);
277
+ });
278
+ child._readyTimer = setTimeout(() => {
279
+ if (panel === child && !panelReady) child.kill("SIGKILL");
280
+ }, 5000);
281
+ child._readyTimer.unref?.();
282
+ child.on("error", (error) => {
283
+ if (panel === child) runtimeStatus.error = error.message;
284
+ });
285
+ child.on("exit", (code, signal) => {
286
+ clearTimeout(child._readyTimer);
287
+ clearTimeout(child._stableTimer);
288
+ if (panel !== child) return;
289
+ panelReady = false;
290
+ runtimeStatus.windowVisible = false;
291
+ runtimeStatus.panelPid = null;
292
+ if (shuttingDown) return;
293
+ if (panelRestartCount < 3) {
294
+ panelRestartCount += 1;
295
+ runtimeStatus.panelRestartCount = panelRestartCount;
296
+ runtimeStatus.state = "starting";
297
+ runtimeStatus.error = null;
298
+ const timer = setTimeout(startPanel, 250 * 2 ** (panelRestartCount - 1));
299
+ timer.unref?.();
300
+ return;
301
+ }
302
+ runtimeStatus.state = "error";
303
+ runtimeStatus.error =
304
+ `Native panel exited (${signal ?? code ?? "unknown"}). ${panelStderr}`.trim();
305
+ });
306
+ }
307
+
308
+ const getNiriWindows = () =>
309
+ readNiriWindows(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
310
+ const getNiriWorkspaces = () =>
311
+ readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
312
+
313
+ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
314
+ if (!isNiriSession()) {
315
+ projection.send();
316
+ return;
317
+ }
318
+ const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
319
+ if (!isCurrent()) return;
320
+ const view = resolveFocusedWorkspaceView(windows, workspaces, projection.getRawSessions(), {
321
+ env: process.env,
322
+ });
323
+ projection.publishWorkspaceView(
324
+ view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
325
+ );
326
+ });
327
+
328
+ async function shutdown(exitCode = 0) {
329
+ if (shuttingDown) return;
330
+ shuttingDown = true;
331
+ watcher?.stop();
332
+ writePanel({
333
+ protocol: 1,
334
+ type: "view",
335
+ revision: Number.MAX_SAFE_INTEGER,
336
+ visible: false,
337
+ sessions: [],
338
+ });
339
+ panel?.stdin?.end();
340
+ await new Promise((resolve) => setTimeout(resolve, 80));
341
+ panel?.kill("SIGTERM");
342
+ await broker?.stop();
343
+ process.exitCode = exitCode;
344
+ }
345
+
346
+ async function main() {
347
+ if (process.env.PI_ACTIVITY_STRIP_RUNTIME_LOCK_HELD !== "1") {
348
+ throw new Error("Native runtime must be launched through the flock-guarded CLI.");
349
+ }
350
+ broker = await createActivityStripBroker({
351
+ focusSession,
352
+ focusStrip,
353
+ getRuntimeStatus: () => ({
354
+ ...runtimeStatus,
355
+ warnings: [...(runtimeStatus.warnings ?? [])],
356
+ }),
357
+ });
358
+ broker.on("snapshot", (snapshot) => {
359
+ const membershipChanged = !haveSameRecordMembership(
360
+ projection.getRawSessions(),
361
+ snapshot.sessions,
362
+ );
363
+ projection.updateSnapshot(snapshot);
364
+ if (isNiriSession() && membershipChanged) reconcileRunner.request();
365
+ });
366
+ broker.on("shutdown-requested", () => void shutdown(0));
367
+
368
+ startPanel();
369
+ if (isNiriSession()) {
370
+ reconcileRunner.request();
371
+ watcher = createNiriWorkspaceEventWatcher({
372
+ spawn,
373
+ env: process.env,
374
+ onFocusedWorkspace: () => reconcileRunner.request(),
375
+ onFallback: () => reconcileRunner.request(),
376
+ fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
377
+ });
378
+ }
379
+
380
+ process.once("SIGINT", () => void shutdown(0));
381
+ process.once("SIGTERM", () => void shutdown(0));
382
+ }
383
+
384
+ main().catch(async (error) => {
385
+ runtimeStatus.state = "error";
386
+ runtimeStatus.error = error instanceof Error ? error.message : String(error);
387
+ console.error(error?.stack ?? error);
388
+ try {
389
+ await shutdown(1);
390
+ } catch {
391
+ process.exitCode = 1;
392
+ }
393
+ });
@@ -0,0 +1,99 @@
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 { resolveSnapshotSession } from "../common/niri-focus.mjs";
8
+ import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
9
+
10
+ /** @typedef {Record<string, unknown>} SessionRecord */
11
+ /** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
12
+ /** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
13
+ /** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
14
+
15
+ /** @param {NativePanelProjectionOptions} options */
16
+ export function createNativePanelProjection({ isNiriSession, publish }) {
17
+ /** @type {Snapshot} */
18
+ let snapshot = { generatedAt: Date.now(), sessions: [] };
19
+ /** @type {string | null} */
20
+ let focusedSessionId = null;
21
+ /** @type {string | null} */
22
+ let focusedCardId = null;
23
+ /** @type {Record<string, unknown> | null} */
24
+ let workspace = null;
25
+ /** @type {Set<string>} */
26
+ let workspaceCardIds = new Set();
27
+ /** @type {Set<string>} */
28
+ let workspaceRecordKeys = new Set();
29
+ let revision = 0;
30
+
31
+ function getDisplaySessions() {
32
+ const sessions = isNiriSession()
33
+ ? snapshot.sessions.filter((session) => workspaceRecordKeys.has(sessionRecordKey(session)))
34
+ : snapshot.sessions;
35
+ return projectSessionCards(sessions, isNiriSession() ? workspaceCardIds : null);
36
+ }
37
+
38
+ function currentView() {
39
+ revision += 1;
40
+ const sessions = getDisplaySessions();
41
+ return {
42
+ protocol: 1,
43
+ type: "view",
44
+ revision,
45
+ visible: isNiriSession() ? Boolean(workspace?.is_focused && sessions.length > 0) : true,
46
+ focusedSessionId,
47
+ focusedCardId,
48
+ generatedAt: snapshot.generatedAt,
49
+ sessions,
50
+ };
51
+ }
52
+
53
+ function send() {
54
+ publish(currentView());
55
+ }
56
+
57
+ function retainValidFocus() {
58
+ const targetId = focusedCardId ?? focusedSessionId;
59
+ if (!targetId || !resolveSnapshotSession(getDisplaySessions(), targetId)) {
60
+ focusedSessionId = null;
61
+ focusedCardId = null;
62
+ }
63
+ }
64
+
65
+ return {
66
+ getDisplaySessions,
67
+ getRawSessions: () => snapshot.sessions,
68
+ /** @param {string} targetId */
69
+ resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
70
+ /** @param {WorkspaceView} view */
71
+ publishWorkspaceView(view) {
72
+ workspace = view.workspace ?? null;
73
+ workspaceCardIds = new Set(view.sessions.map((session) => String(session.cardId ?? "")));
74
+ workspaceRecordKeys = new Set(
75
+ view.sessions.flatMap((session) =>
76
+ Array.isArray(session.publisherRecordKeys)
77
+ ? session.publisherRecordKeys.map(String)
78
+ : [sessionRecordKey(session)],
79
+ ),
80
+ );
81
+ focusedSessionId = view.focusedSessionId;
82
+ focusedCardId = view.focusedCardId ?? null;
83
+ send();
84
+ },
85
+ send,
86
+ /** @param {SessionRecord} session */
87
+ setFocused(session) {
88
+ focusedSessionId = String(session.sessionId ?? "") || null;
89
+ focusedCardId = String(session.cardId ?? "") || null;
90
+ send();
91
+ },
92
+ /** @param {Snapshot} nextSnapshot */
93
+ updateSnapshot(nextSnapshot) {
94
+ snapshot = nextSnapshot;
95
+ retainValidFocus();
96
+ send();
97
+ },
98
+ };
99
+ }