@tryinget/pi-activity-strip 0.5.0 → 0.7.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 (46) hide show
  1. package/README.md +59 -7
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +98 -64
  4. package/extensions/activity-strip.js +25 -21
  5. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/src/app.rs +20 -20
  8. package/native/panel/src/card_view.rs +396 -9
  9. package/native/panel/src/main.rs +64 -1
  10. package/native/panel/src/protocol.rs +142 -0
  11. package/native/panel/src/runtime.rs +50 -9
  12. package/native/panel/src/style.css +147 -60
  13. package/package.json +4 -4
  14. package/src/client/runtime-lock.mjs +140 -0
  15. package/src/common/agent-identity.mjs +163 -0
  16. package/src/common/ak-tasks.mjs +354 -0
  17. package/src/common/claude-events.mjs +190 -0
  18. package/src/common/claude-hook-config.mjs +56 -0
  19. package/src/common/claude-transcript.mjs +163 -0
  20. package/src/common/codex-transcript.mjs +153 -0
  21. package/src/common/compatibility.mjs +13 -0
  22. package/src/common/constants.mjs +2 -0
  23. package/src/common/contracts.d.ts +35 -0
  24. package/src/common/contracts.ts +67 -1
  25. package/src/common/ghostty-present.mjs +100 -0
  26. package/src/common/ghostty-theme.mjs +149 -0
  27. package/src/common/niri-focus.mjs +179 -111
  28. package/src/common/session-cards.mjs +5 -2
  29. package/src/common/status-report.mjs +51 -0
  30. package/src/common/surface-bindings.mjs +234 -0
  31. package/src/common/terminal-identity.mjs +75 -12
  32. package/src/common/window-placement.mjs +59 -0
  33. package/src/common/workspace-view.mjs +161 -0
  34. package/src/native/agent-discovery.mjs +294 -0
  35. package/src/native/ak-runtime.mjs +164 -0
  36. package/src/native/codex-discovery.mjs +128 -0
  37. package/src/native/ghostty-tab-inventory.py +99 -0
  38. package/src/native/height-repair.mjs +154 -0
  39. package/src/native/main.mjs +122 -64
  40. package/src/native/panel-binary.mjs +56 -0
  41. package/src/native/panel-projection.mjs +70 -5
  42. package/src/native/placement.mjs +225 -0
  43. package/src/native/tab-inventory.mjs +188 -0
  44. package/src/native/theme-runtime.mjs +72 -0
  45. package/src/native/theme-source.mjs +197 -0
  46. package/src/native/workspace-events.mjs +22 -1
@@ -0,0 +1,154 @@
1
+ // ---
2
+ // summary: "restores tiled windows stranded at the wrong height when the ribbon's exclusive zone appears or disappears"
3
+ // read_when:
4
+ // - "changing height repair, its evidence rules, or its safety bounds"
5
+ // ---
6
+
7
+ export const HEIGHT_REPAIR_SETTLE_MS = 300;
8
+ export const HEIGHT_REPAIR_MAX_PER_TRANSITION = 48;
9
+
10
+ /** @typedef {{id?: unknown; is_floating?: unknown; layout?: {window_size?: number[]}}} NiriWindow */
11
+ /** @typedef {{id: number; height: number}} StaleWindow */
12
+
13
+ /** @param {NiriWindow} window */
14
+ function tiledHeight(window) {
15
+ if (window?.is_floating === true) return 0;
16
+ const height = Number(window?.layout?.window_size?.[1] ?? 0);
17
+ return Number.isFinite(height) && height > 0 ? height : 0;
18
+ }
19
+
20
+ /** @param {NiriWindow[]} windows */
21
+ function heightsById(windows) {
22
+ /** @type {Map<number, number>} */
23
+ const heights = new Map();
24
+ for (const window of windows ?? []) {
25
+ if (!Number.isInteger(window?.id)) continue;
26
+ const height = tiledHeight(window);
27
+ if (height > 0) heights.set(Number(window.id), height);
28
+ }
29
+ return heights;
30
+ }
31
+
32
+ /**
33
+ * Decide which windows the compositor left behind. Changing the exclusive zone changes the output
34
+ * working area, so every window whose height is automatic moves. A window that did not move while
35
+ * others did is only treated as stale when it still sits at a height those movers just vacated:
36
+ * that is positive evidence of the old working area, so a deliberately chosen height of any other
37
+ * value is never touched.
38
+ * @param {NiriWindow[]} before
39
+ * @param {NiriWindow[]} after
40
+ * @returns {StaleWindow[]}
41
+ */
42
+ export function findStaleWindows(before, after) {
43
+ const previous = heightsById(before);
44
+ const current = heightsById(after);
45
+ /** @type {Set<number>} */
46
+ const vacated = new Set();
47
+ for (const [id, height] of current) {
48
+ const wasHeight = previous.get(id);
49
+ if (wasHeight !== undefined && wasHeight !== height) vacated.add(wasHeight);
50
+ }
51
+ if (vacated.size === 0) return [];
52
+
53
+ /** @type {StaleWindow[]} */
54
+ const stale = [];
55
+ for (const [id, height] of current) {
56
+ const wasHeight = previous.get(id);
57
+ if (wasHeight === undefined || wasHeight !== height) continue;
58
+ if (vacated.has(height)) stale.push({ id, height });
59
+ }
60
+ return stale.sort((left, right) => left.id - right.id);
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
66
+ * env?: NodeJS.ProcessEnv;
67
+ * runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
68
+ * readWindows: () => Promise<NiriWindow[]>;
69
+ * sleep?: (milliseconds: number) => Promise<void>;
70
+ * settleMs?: number;
71
+ * }} options
72
+ */
73
+ export function createHeightRepair({
74
+ execFileAsync,
75
+ env = process.env,
76
+ runtimeStatus,
77
+ readWindows,
78
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
79
+ settleMs = HEIGHT_REPAIR_SETTLE_MS,
80
+ }) {
81
+ const enabled = env.PI_ACTIVITY_STRIP_HEIGHT_REPAIR !== "0";
82
+ /** @type {NiriWindow[]} */
83
+ let lastWindows = [];
84
+ /** Windows already reset at a given height, so one pass can never loop on one window. */
85
+ /** @type {Set<string>} */
86
+ const repaired = new Set();
87
+ let inFlight = false;
88
+ runtimeStatus.heightRepairState = enabled ? "enabled" : "disabled";
89
+ runtimeStatus.heightRepairCount = 0;
90
+ runtimeStatus.lastHeightRepairAt = 0;
91
+
92
+ /** @param {NiriWindow[]} windows */
93
+ function observe(windows) {
94
+ if (!Array.isArray(windows) || windows.length === 0) return;
95
+ // A window seen at a height other than the one it was reset at has moved since, so a future
96
+ // stranding there is a new event and must not be suppressed by the earlier repair.
97
+ for (const window of windows) {
98
+ if (!Number.isInteger(window?.id)) continue;
99
+ const height = tiledHeight(window);
100
+ if (height <= 0) continue;
101
+ for (const key of repaired) {
102
+ const [id, at] = key.split(":");
103
+ if (Number(id) === Number(window.id) && Number(at) !== height) repaired.delete(key);
104
+ }
105
+ }
106
+ lastWindows = windows;
107
+ }
108
+
109
+ /**
110
+ * Run after the panel reports that it applied a visibility change, which is the only moment the
111
+ * exclusive zone changes. One pass at a time; a pass never retries a window it already reset.
112
+ */
113
+ async function repairAfterZoneChange() {
114
+ if (!enabled || inFlight) return [];
115
+ const before = lastWindows;
116
+ if (before.length === 0) return [];
117
+ inFlight = true;
118
+ try {
119
+ await sleep(settleMs);
120
+ const after = await readWindows();
121
+ if (after.length === 0) return [];
122
+ lastWindows = after;
123
+ const stale = findStaleWindows(before, after).filter(
124
+ (window) => !repaired.has(`${window.id}:${window.height}`),
125
+ );
126
+ const bounded = stale.slice(0, HEIGHT_REPAIR_MAX_PER_TRANSITION);
127
+ /** @type {number[]} */
128
+ const applied = [];
129
+ for (const window of bounded) {
130
+ try {
131
+ await execFileAsync(
132
+ "niri",
133
+ ["msg", "action", "reset-window-height", "--id", String(window.id)],
134
+ { env },
135
+ );
136
+ repaired.add(`${window.id}:${window.height}`);
137
+ applied.push(window.id);
138
+ } catch {
139
+ // A window that closed mid-pass, or a rejected action, is left to the next transition.
140
+ }
141
+ }
142
+ if (applied.length > 0) {
143
+ runtimeStatus.heightRepairCount =
144
+ Number(runtimeStatus.heightRepairCount ?? 0) + applied.length;
145
+ runtimeStatus.lastHeightRepairAt = Date.now();
146
+ }
147
+ return applied;
148
+ } finally {
149
+ inFlight = false;
150
+ }
151
+ }
152
+
153
+ return { observe, repairAfterZoneChange, enabled };
154
+ }
@@ -6,30 +6,27 @@
6
6
  // ---
7
7
 
8
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
9
  import { promisify } from "node:util";
14
10
  import { createActivityStripBroker } from "../broker/server.mjs";
15
11
  import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
12
+ import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
16
13
  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";
14
+ import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
23
15
  import { haveSameRecordMembership } from "../common/session-cards.mjs";
16
+ import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
17
+ import { discoverAgentTabs } from "./agent-discovery.mjs";
18
+ import { startAkTaskProjection } from "./ak-runtime.mjs";
19
+ import { createHeightRepair } from "./height-repair.mjs";
20
+ import { resolvePanelBinary } from "./panel-binary.mjs";
24
21
  import { createNativePanelProjection } from "./panel-projection.mjs";
22
+ import { createPlacementRuntime } from "./placement.mjs";
23
+ import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
25
24
  import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
26
25
 
27
26
  /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
28
27
  /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
29
28
  /** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
30
29
 
31
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
- const packageRoot = path.resolve(__dirname, "..", "..");
33
30
  const execFileAsync = promisify(execFile);
34
31
 
35
32
  /** @type {import("../broker/server.mjs").ActivityStripBroker | null} */
@@ -66,6 +63,15 @@ const runtimeStatus = {
66
63
  lastMovedCardId: null,
67
64
  rendererCardCount: 0,
68
65
  rendererCardIds: [],
66
+ rendererHiddenTabCardCount: 0,
67
+ agentTabCount: 0,
68
+ surfaceBindingCount: 0,
69
+ unplacedSurfaceCount: 0,
70
+ tabInventoryState: process.env.PI_ACTIVITY_STRIP_TAB_INVENTORY === "0" ? "disabled" : "pending",
71
+ tabInventoryDetail: null,
72
+ tabInventoryFrameCount: 0,
73
+ tabInventoryTabCount: 0,
74
+ tabInventoryProbedAt: null,
69
75
  warnings: [],
70
76
  error: null,
71
77
  };
@@ -74,48 +80,14 @@ function isNiriSession() {
74
80
  return Boolean(process.env.NIRI_SOCKET);
75
81
  }
76
82
 
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
- }
83
+ // Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
84
+ // memory learned from visible titles and the bounded AT-SPI tab inventory.
85
+ const placement = createPlacementRuntime({
86
+ env: process.env,
87
+ runtimeStatus,
88
+ execFileAsync,
89
+ onBindingsLearned: () => reconcileRunner.request(),
90
+ });
119
91
 
120
92
  /** @param {PanelMessage} message */
121
93
  function writePanel(message) {
@@ -150,19 +122,32 @@ const projection = createNativePanelProjection({
150
122
  publish(view) {
151
123
  runtimeStatus.rendererCardCount = view.sessions.length;
152
124
  runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
125
+ runtimeStatus.rendererHiddenTabCardCount = view.sessions.filter(
126
+ (session) => session.surfaceVisible === false,
127
+ ).length;
128
+ runtimeStatus.akTaskRenderedCount = view.sessions.reduce(
129
+ (total, session) => total + (Array.isArray(session.akTasks) ? session.akTasks.length : 0),
130
+ 0,
131
+ );
153
132
  writePanel(view);
154
133
  },
155
134
  });
156
135
 
157
- /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number}>} */
136
+ // AK task references are a read-only projection of claims held by live sessions; the strip
137
+ // never writes AK state and renders nothing when the read fails.
138
+ startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
139
+
140
+ /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
158
141
  async function focusSession(targetId) {
159
142
  const session = projection.resolveTarget(targetId);
160
143
  if (!session) {
161
144
  return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
162
145
  }
163
- const rawResult = await focusNiriSession(session, execFileAsync, process.env);
146
+ const rawResult = await focusNiriSession(session, execFileAsync, process.env, placement.options);
164
147
  const result = {
165
148
  ok: rawResult.ok === true,
149
+ presented: rawResult.presented === true,
150
+ verified: rawResult.verified === true,
166
151
  ...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
167
152
  ...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
168
153
  };
@@ -212,6 +197,7 @@ function consumePanelEvents(child, chunk) {
212
197
  }
213
198
  }, 30_000);
214
199
  child._stableTimer.unref?.();
200
+ theme.republish();
215
201
  if (pendingView) {
216
202
  const view = pendingView;
217
203
  pendingView = null;
@@ -220,7 +206,12 @@ function consumePanelEvents(child, chunk) {
220
206
  projection.send();
221
207
  }
222
208
  } else if (event.type === "visibility-applied") {
223
- runtimeStatus.windowVisible = event.visible === true;
209
+ const visible = event.visible === true;
210
+ const changed = runtimeStatus.windowVisible !== visible;
211
+ runtimeStatus.windowVisible = visible;
212
+ // Showing or hiding the surface is the only moment the exclusive zone changes, so it is
213
+ // the only moment a tiled window can be left at the previous working area's height.
214
+ if (changed) void heightRepair.repairAfterZoneChange();
224
215
  } else if (event.type === "expanded") {
225
216
  runtimeStatus.panelExpanded = event.expanded === true;
226
217
  } else if (event.type === "activate") {
@@ -232,7 +223,11 @@ function consumePanelEvents(child, chunk) {
232
223
  cardId,
233
224
  ok: result.ok === true,
234
225
  message: result.ok
235
- ? "Focused Ghostty window."
226
+ ? result.presented
227
+ ? result.verified
228
+ ? "Presented the hidden tab and focused its Ghostty window."
229
+ : "Focused its Ghostty window and asked Ghostty to show the tab."
230
+ : "Focused Ghostty window."
236
231
  : result.error || "Focus failed; nothing moved.",
237
232
  }),
238
233
  );
@@ -254,9 +249,8 @@ function consumePanelEvents(child, chunk) {
254
249
 
255
250
  function startPanel() {
256
251
  if (shuttingDown) return;
257
- const binary = panelBinaryPath();
258
252
  /** @type {PanelChild} */
259
- const child = spawn(binary, [], {
253
+ const child = spawn(resolvePanelBinary(process.env), [], {
260
254
  env: process.env,
261
255
  stdio: ["pipe", "pipe", "pipe"],
262
256
  });
@@ -310,6 +304,49 @@ const getNiriWindows = () =>
310
304
  const getNiriWorkspaces = () =>
311
305
  readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
312
306
 
307
+ // Agent tabs are read from the process table, not the broker, so they are refreshed on a calm
308
+ // clock of their own rather than on every workspace event.
309
+ const AGENT_SCAN_INTERVAL_MS = 3000;
310
+ let agentScanAt = 0;
311
+
312
+ function refreshAgentTabs() {
313
+ if (process.env.PI_ACTIVITY_STRIP_AGENT_TABS === "0") return;
314
+ if (Date.now() - agentScanAt < AGENT_SCAN_INTERVAL_MS) return;
315
+ agentScanAt = Date.now();
316
+ try {
317
+ const records = discoverAgentTabs({ env: process.env });
318
+ runtimeStatus.agentTabCount = records.length;
319
+ runtimeStatus.agentScanError = null;
320
+ projection.setAgentSessions(records);
321
+ // A session that dies without firing its end hook leaves a record behind; retire the ones no
322
+ // live tab claims so they cannot accumulate.
323
+ pruneClaudeEventRecords(
324
+ records
325
+ .filter((record) => record.agentKind === "claude" && record.agentSessionKey)
326
+ .map((record) => String(record.agentSessionKey)),
327
+ );
328
+ } catch (error) {
329
+ // Recorded on its own field: replacing the shared warning list would erase a panel error, and
330
+ // a transient scan failure must not leave a permanent warning behind.
331
+ runtimeStatus.agentScanError = error instanceof Error ? error.message : String(error);
332
+ }
333
+ }
334
+
335
+ // The ribbon reads Ghostty's own theme, so it follows the terminal into light and dark.
336
+ const theme = createThemeRuntime({
337
+ execFileAsync,
338
+ env: process.env,
339
+ runtimeStatus,
340
+ publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
341
+ });
342
+
343
+ const heightRepair = createHeightRepair({
344
+ execFileAsync,
345
+ env: process.env,
346
+ runtimeStatus,
347
+ readWindows: getNiriWindows,
348
+ });
349
+
313
350
  const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
314
351
  if (!isNiriSession()) {
315
352
  projection.send();
@@ -317,18 +354,25 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
317
354
  }
318
355
  const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
319
356
  if (!isCurrent()) return;
320
- const view = resolveFocusedWorkspaceView(windows, workspaces, projection.getRawSessions(), {
321
- env: process.env,
322
- });
357
+ placement.observeWindowList(windows);
358
+ heightRepair.observe(windows);
359
+ refreshAgentTabs();
360
+ runtimeStatus.surfaceBindingCount = placement.bindingCount;
361
+ const sessions = projection.getRawSessions();
362
+ runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
363
+ const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
364
+ if (view) placement.learnFromPlacements(view.sessions, windows);
323
365
  projection.publishWorkspaceView(
324
366
  view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
325
367
  );
368
+ void placement.scheduleInventory(windows);
326
369
  });
327
370
 
328
371
  async function shutdown(exitCode = 0) {
329
372
  if (shuttingDown) return;
330
373
  shuttingDown = true;
331
374
  watcher?.stop();
375
+ placement.persist();
332
376
  writePanel({
333
377
  protocol: 1,
334
378
  type: "view",
@@ -356,8 +400,10 @@ async function main() {
356
400
  }),
357
401
  });
358
402
  broker.on("snapshot", (snapshot) => {
403
+ // Compare like with like: the raw set also holds discovered agent tabs, which the broker
404
+ // never publishes, so comparing it to a broker snapshot would always differ and reconcile.
359
405
  const membershipChanged = !haveSameRecordMembership(
360
- projection.getRawSessions(),
406
+ projection.getBrokerSessions(),
361
407
  snapshot.sessions,
362
408
  );
363
409
  projection.updateSnapshot(snapshot);
@@ -373,10 +419,22 @@ async function main() {
373
419
  env: process.env,
374
420
  onFocusedWorkspace: () => reconcileRunner.request(),
375
421
  onFallback: () => reconcileRunner.request(),
422
+ onWindowChanged: (window) => {
423
+ if (placement.observeWindowEvent(window)) reconcileRunner.request();
424
+ },
425
+ onWindowClosed: (windowId) => {
426
+ if (placement.forgetWindow(windowId)) reconcileRunner.request();
427
+ },
428
+ onWindowFocusChanged: () => reconcileRunner.request(),
429
+ onWorkspacesChanged: () => reconcileRunner.request(),
376
430
  fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
377
431
  });
378
432
  }
379
433
 
434
+ void theme.refresh({ force: true });
435
+ const themeTimer = setInterval(() => void theme.refresh(), THEME_POLL_INTERVAL_MS);
436
+ themeTimer.unref?.();
437
+
380
438
  process.once("SIGINT", () => void shutdown(0));
381
439
  process.once("SIGTERM", () => void shutdown(0));
382
440
  }
@@ -0,0 +1,56 @@
1
+ // ---
2
+ // summary: "locates the native panel binary and verifies it against its reviewed artifact receipt"
3
+ // read_when:
4
+ // - "changing which panel binary the controller starts or how its receipt is checked"
5
+ // ---
6
+
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
13
+
14
+ /** @param {NodeJS.ProcessEnv} env */
15
+ export function resolvePanelBinary(env) {
16
+ const override = env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
17
+ const allowUnverified = env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
18
+ const candidates = [
19
+ ...(override ? [override] : []),
20
+ path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
21
+ ...(allowUnverified
22
+ ? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
23
+ : []),
24
+ ].filter(Boolean);
25
+ const binary = candidates.find((candidate) => {
26
+ try {
27
+ fs.accessSync(candidate, fs.constants.X_OK);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ });
33
+ if (!binary) {
34
+ throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
35
+ }
36
+ if (allowUnverified) return binary;
37
+ if (process.platform !== "linux" || process.arch !== "x64") {
38
+ throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
39
+ }
40
+ const artifactPath = path.join(path.dirname(binary), "artifact.json");
41
+ let artifact;
42
+ try {
43
+ artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
44
+ } catch {
45
+ throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
46
+ }
47
+ const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
48
+ if (
49
+ artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
50
+ artifact.target !== "x86_64-unknown-linux-gnu" ||
51
+ artifact.sha256 !== digest
52
+ ) {
53
+ throw new Error("Native panel binary does not match its reviewed artifact receipt.");
54
+ }
55
+ return binary;
56
+ }
@@ -4,18 +4,23 @@
4
4
  // - "changing native panel card membership, focus, or transport revisions"
5
5
  // ---
6
6
 
7
+ import { joinAkTaskChips } from "../common/ak-tasks.mjs";
7
8
  import { resolveSnapshotSession } from "../common/niri-focus.mjs";
8
9
  import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
9
10
 
10
11
  /** @typedef {Record<string, unknown>} SessionRecord */
11
12
  /** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
12
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; workspaceIdx: number | null}} CardPlacement */
13
15
  /** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
14
16
 
15
17
  /** @param {NativePanelProjectionOptions} options */
16
18
  export function createNativePanelProjection({ isNiriSession, publish }) {
17
19
  /** @type {Snapshot} */
18
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 = [];
19
24
  /** @type {string | null} */
20
25
  let focusedSessionId = null;
21
26
  /** @type {string | null} */
@@ -26,13 +31,44 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
26
31
  let workspaceCardIds = new Set();
27
32
  /** @type {Set<string>} */
28
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;
29
39
  let revision = 0;
30
40
 
41
+ function allSessions() {
42
+ return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
43
+ }
44
+
31
45
  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);
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;
36
72
  }
37
73
 
38
74
  function currentView() {
@@ -64,7 +100,23 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
64
100
 
65
101
  return {
66
102
  getDisplaySessions,
67
- getRawSessions: () => snapshot.sessions,
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
+ },
68
120
  /** @param {string} targetId */
69
121
  resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
70
122
  /** @param {WorkspaceView} view */
@@ -78,6 +130,19 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
78
130
  : [sessionRecordKey(session)],
79
131
  ),
80
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
+ workspaceIdx: Number.isInteger(session.workspaceIdx)
141
+ ? Number(session.workspaceIdx)
142
+ : null,
143
+ },
144
+ ]),
145
+ );
81
146
  focusedSessionId = view.focusedSessionId;
82
147
  focusedCardId = view.focusedCardId ?? null;
83
148
  send();