@tryinget/pi-activity-strip 0.4.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 (40) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/Cargo.lock +1045 -0
  6. package/native/panel/Cargo.toml +23 -0
  7. package/native/panel/src/app.rs +606 -0
  8. package/native/panel/src/card_view.rs +303 -0
  9. package/native/panel/src/main.rs +31 -0
  10. package/native/panel/src/protocol.rs +234 -0
  11. package/native/panel/src/runtime.rs +110 -0
  12. package/native/panel/src/style.css +176 -0
  13. package/package.json +13 -1
  14. package/src/broker/server.mjs +43 -7
  15. package/src/broker/session-store.mjs +41 -13
  16. package/src/client/broker-client.mjs +10 -3
  17. package/src/client/session-telemetry.mjs +54 -14
  18. package/src/common/activity-order.mjs +15 -9
  19. package/src/common/alignment-controller.mjs +24 -42
  20. package/src/common/card-display.mjs +5 -0
  21. package/src/common/compatibility.mjs +15 -13
  22. package/src/common/constants.mjs +10 -28
  23. package/src/common/contracts.d.ts +42 -4
  24. package/src/common/contracts.ts +42 -4
  25. package/src/common/niri-focus.mjs +79 -79
  26. package/src/common/protocol.mjs +48 -16
  27. package/src/common/session-cards.mjs +102 -0
  28. package/src/common/status-report.mjs +2 -1
  29. package/src/common/telemetry.mjs +8 -1
  30. package/src/common/terminal-identity.mjs +149 -0
  31. package/src/native/main.mjs +393 -0
  32. package/src/native/panel-projection.mjs +99 -0
  33. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  34. package/src/common/electron.mjs +0 -57
  35. package/src/electron/main.mjs +0 -499
  36. package/src/electron/niri-native-window-runtime.mjs +0 -178
  37. package/src/electron/preload.cjs +0 -50
  38. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  39. package/src/electron/workspace-view-runtime.mjs +0 -360
  40. package/src/ui/strip-html.mjs +0 -500
@@ -1,35 +1,9 @@
1
1
  // ---
2
- // summary: "coalesces native-window alignment so stale async work cannot overwrite newer geometry"
2
+ // summary: "coalesces asynchronous reconciliation so stale work cannot publish over newer state"
3
3
  // read_when:
4
- // - "changing Electron/Niri geometry reconciliation or testing rapid expansion transitions"
4
+ // - "changing latest-only native workspace reconciliation"
5
5
  // ---
6
6
 
7
- /**
8
- * @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown } } | null | undefined} window
9
- */
10
- export function hasNiriFloatingPosition(window) {
11
- const position = window?.layout?.tile_pos_in_workspace_view;
12
- return window?.is_floating === true && Array.isArray(position) && position.length >= 2;
13
- }
14
-
15
- /**
16
- * @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown, window_size?: unknown } } | null | undefined} window
17
- * @param {{x: number, y: number, width: number, height: number}} target
18
- */
19
- export function isNiriWindowAligned(window, target) {
20
- if (!hasNiriFloatingPosition(window)) return false;
21
- const position = window?.layout?.tile_pos_in_workspace_view;
22
- const size = window?.layout?.window_size;
23
- return Boolean(
24
- Array.isArray(position) &&
25
- Array.isArray(size) &&
26
- Number(size[0]) === target.width &&
27
- Number(size[1]) === target.height &&
28
- Math.abs(Number(position[0]) - target.x) < 1 &&
29
- Math.abs(Number(position[1]) - target.y) < 1,
30
- );
31
- }
32
-
33
7
  /**
34
8
  * @param {(attempt: { generation: number, isCurrent: () => boolean }) => Promise<void>} run
35
9
  */
@@ -37,34 +11,42 @@ export function createLatestOnlyRunner(run) {
37
11
  if (typeof run !== "function") throw new TypeError("run must be a function");
38
12
 
39
13
  let requestedGeneration = 0;
14
+ let completedGeneration = 0;
40
15
  /** @type {Promise<void> | null} */
41
16
  let worker = null;
42
17
 
18
+ function ensureWorker() {
19
+ if (!worker) worker = drain();
20
+ }
21
+
43
22
  async function drain() {
44
- while (true) {
45
- const generation = requestedGeneration;
46
- const isCurrent = () => generation === requestedGeneration;
47
- try {
48
- await run({ generation, isCurrent });
49
- } catch {
50
- // Alignment is best effort. A newer request must still be allowed to reconcile the window.
23
+ try {
24
+ while (completedGeneration < requestedGeneration) {
25
+ const generation = requestedGeneration;
26
+ const isCurrent = () => generation === requestedGeneration;
27
+ try {
28
+ await run({ generation, isCurrent });
29
+ } catch {
30
+ // Alignment is best effort. A newer request must still be allowed to reconcile the window.
31
+ }
32
+ completedGeneration = generation;
51
33
  }
52
- if (isCurrent()) return;
34
+ } finally {
35
+ worker = null;
36
+ // A request can arrive after the loop condition was checked but before this finally block.
37
+ // Hand it to a successor synchronously so no requested generation is lost.
38
+ if (completedGeneration < requestedGeneration) ensureWorker();
53
39
  }
54
40
  }
55
41
 
56
42
  function request() {
57
43
  requestedGeneration += 1;
58
- if (!worker) {
59
- worker = drain().finally(() => {
60
- worker = null;
61
- });
62
- }
44
+ ensureWorker();
63
45
  return requestedGeneration;
64
46
  }
65
47
 
66
48
  async function waitForIdle() {
67
- await worker;
49
+ while (worker) await worker;
68
50
  }
69
51
 
70
52
  return {
@@ -46,3 +46,8 @@ export function disambiguatedRepoLabel(session, duplicateLabels) {
46
46
  ? `${label} · ${String(processId).slice(-4)}`
47
47
  : label;
48
48
  }
49
+
50
+ /** @param {{hovered?: boolean; activeElement?: boolean; documentFocused?: boolean}} state */
51
+ export function shouldRetainExpandedCard({ hovered, activeElement, documentFocused }) {
52
+ return Boolean(hovered || (documentFocused && activeElement));
53
+ }
@@ -1,12 +1,11 @@
1
1
  // ---
2
- // summary: "detects desktop and Electron prerequisites and formats actionable activity-strip compatibility reports"
2
+ // summary: "detects native layer-shell prerequisites and formats actionable activity-strip compatibility reports"
3
3
  // read_when:
4
4
  // - "changing host detection, compatibility blockers, warnings, or doctor output"
5
5
  // ---
6
6
 
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
- import { locateElectron } from "./electron.mjs";
10
9
 
11
10
  const execFileAsync = promisify(execFile);
12
11
 
@@ -67,14 +66,17 @@ export async function detectDisplayCount(options = {}) {
67
66
  /**
68
67
  * @param {{
69
68
  * env?: NodeJS.ProcessEnv;
70
- * locateElectronImpl?: typeof locateElectron;
71
69
  * execFileAsyncImpl?: typeof execFileAsync;
70
+ * platform?: NodeJS.Platform;
71
+ * arch?: string;
72
72
  * }} [options]
73
73
  * @returns {Promise<import("./contracts.ts").ActivityStripCompatibilityReport>}
74
74
  */
75
75
  export async function assessActivityStripCompatibility(options = {}) {
76
76
  const env = options.env || process.env;
77
- const locateElectronImpl = options.locateElectronImpl || locateElectron;
77
+ const backend = "native";
78
+ const platform = options.platform ?? process.platform;
79
+ const arch = options.arch ?? process.arch;
78
80
  const displayServer = detectDisplayServer(env);
79
81
  const windowManager = detectWindowManager(env);
80
82
  const displayCount = await detectDisplayCount({
@@ -83,7 +85,6 @@ export async function assessActivityStripCompatibility(options = {}) {
83
85
  });
84
86
  const blockers = [];
85
87
  const warnings = [];
86
- let electronPath = null;
87
88
 
88
89
  if (displayServer === "headless") {
89
90
  blockers.push(
@@ -97,10 +98,11 @@ export async function assessActivityStripCompatibility(options = {}) {
97
98
  );
98
99
  }
99
100
 
100
- try {
101
- electronPath = await locateElectronImpl();
102
- } catch (error) {
103
- blockers.push(error instanceof Error ? error.message : String(error));
101
+ if (displayServer !== "headless" && displayServer !== "wayland") {
102
+ blockers.push("The native Activity Strip panel requires a Wayland session.");
103
+ }
104
+ if (platform !== "linux" || arch !== "x64") {
105
+ blockers.push(`The packaged native panel requires Linux x64; detected ${platform} ${arch}.`);
104
106
  }
105
107
 
106
108
  if (displayCount && displayCount > 1) {
@@ -111,17 +113,17 @@ export async function assessActivityStripCompatibility(options = {}) {
111
113
 
112
114
  if (windowManager !== "niri") {
113
115
  warnings.push(
114
- "Top-edge repair is optimized for Niri. Other window managers fall back to generic Electron bounds and may need manual adjustment.",
116
+ "Workspace-local projection is optimized for Niri; other layer-shell compositors show the global card view.",
115
117
  );
116
118
  }
117
119
 
118
120
  return {
119
121
  ok: blockers.length === 0,
122
+ backend,
120
123
  displayServer,
121
124
  windowManager: typeof windowManager === "string" ? windowManager : null,
122
- electronPath,
123
125
  displayCount,
124
- alignmentMode: windowManager === "niri" ? "niri" : "generic",
126
+ alignmentMode: "layer-shell",
125
127
  primaryDisplayOnly: true,
126
128
  clickThroughDefault: env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
127
129
  blockers,
@@ -137,7 +139,7 @@ export function formatCompatibilityReport(report) {
137
139
  `Compatibility: ${report.ok ? "compatible" : "blocked"}`,
138
140
  `Display server: ${report.displayServer}`,
139
141
  `Window manager: ${report.windowManager || "unknown"}`,
140
- `Electron: ${report.electronPath || "not found"}`,
142
+ `Backend: ${report.backend}`,
141
143
  `Alignment mode: ${report.alignmentMode}`,
142
144
  `Primary-display only: ${report.primaryDisplayOnly ? "yes" : "no"}`,
143
145
  `Click-through mode: ${report.clickThroughDefault ? "enabled by environment" : "disabled (interactive default)"}`,
@@ -1,5 +1,5 @@
1
1
  // ---
2
- // summary: "defines shared socket paths, window dimensions, timing thresholds, and Electron binary candidates"
2
+ // summary: "defines shared broker paths, timing thresholds, and transport bounds"
3
3
  // read_when:
4
4
  // - "tuning activity-strip runtime defaults or locating shared configuration values"
5
5
  // ---
@@ -8,40 +8,22 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
 
10
10
  export const ACTIVITY_STRIP_NAME = "pi-activity-strip";
11
- export const ACTIVITY_STRIP_SOCKET_DIR = path.join(
12
- os.homedir(),
13
- ".pi",
14
- "agent",
15
- "state",
16
- ACTIVITY_STRIP_NAME,
17
- );
18
- export const ACTIVITY_STRIP_SOCKET_PATH = path.join(
19
- ACTIVITY_STRIP_SOCKET_DIR,
20
- "activity-strip.sock",
21
- );
22
- export const ACTIVITY_STRIP_WIDTH_PADDING = 16;
23
- export const ACTIVITY_STRIP_HEIGHT = 84;
24
- export const ACTIVITY_STRIP_EXPANDED_HEIGHT = 252;
11
+ export const ACTIVITY_STRIP_SOCKET_DIR =
12
+ process.env.PI_ACTIVITY_STRIP_SOCKET_DIR?.trim() ||
13
+ path.join(os.homedir(), ".pi", "agent", "state", ACTIVITY_STRIP_NAME);
14
+ export const ACTIVITY_STRIP_SOCKET_PATH =
15
+ process.env.PI_ACTIVITY_STRIP_SOCKET_PATH?.trim() ||
16
+ path.join(ACTIVITY_STRIP_SOCKET_DIR, "activity-strip.sock");
25
17
  export const ACTIVITY_STRIP_ORDER_REFRESH_MS = 15_000;
26
18
  export const ACTIVITY_STRIP_WORKSPACE_SYNC_MS = 1500;
27
- export const ACTIVITY_STRIP_NIRI_ANIMATION_SETTLE_MS = 450;
28
19
  export const ACTIVITY_STRIP_HEARTBEAT_MS = 2500;
29
20
  export const ACTIVITY_STRIP_STALE_AFTER_MS = 12000;
21
+ export const ACTIVITY_STRIP_MAX_SESSIONS = 256;
22
+ export const ACTIVITY_STRIP_MAX_MESSAGE_BYTES = 256 * 1024;
23
+ export const ACTIVITY_STRIP_CLIENT_IDLE_TIMEOUT_MS = 2000;
30
24
  export const ACTIVITY_STRIP_BROADCAST_TICK_MS = 1000;
31
25
  export const ACTIVITY_STRIP_SEND_THROTTLE_MS = 120;
32
26
  export const ACTIVITY_STRIP_CONNECT_TIMEOUT_MS = 450;
33
27
  export const ACTIVITY_STRIP_START_TIMEOUT_MS = 5000;
34
- export const DEFAULT_ELECTRON_CANDIDATES = [
35
- "electron",
36
- "electron39",
37
- "electron38",
38
- "electron37",
39
- "electron36",
40
- "electron35",
41
- "electron34",
42
- "electron33",
43
- "electron32",
44
- "electron31",
45
- ];
46
28
  export const ACTIVITY_STRIP_EVENT_STALL_MS = 15 * 60_000;
47
29
  export const ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS = [250, 750];
@@ -9,7 +9,16 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
9
9
  export interface SessionSnapshot {
10
10
  sessionId: string;
11
11
  publisherId: string;
12
+ publisherSequence: number;
12
13
  processId: number;
14
+ terminalKind: "ghostty-surface" | "unbound";
15
+ terminalKey: string;
16
+ terminalFamily: string;
17
+ terminalSurfaceId: string;
18
+ cardId?: string;
19
+ publisherCount?: number;
20
+ publisherIds?: string[];
21
+ publisherRecordKeys?: string[];
13
22
  cwd: string;
14
23
  repoLabel: string;
15
24
  sessionName: string;
@@ -38,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
38
47
 
39
48
  export interface ActivityStripRuntimeStatus {
40
49
  state: ActivityStripRuntimeState;
50
+ backend?: "native-layer-shell";
41
51
  startedAt: number;
42
52
  readyAt?: number | null;
43
53
  windowVisible?: boolean;
44
54
  displayServer?: "wayland" | "x11" | "headless" | "unknown";
45
55
  windowManager?: string | null;
46
56
  displayCount?: number | null;
47
- alignmentMode?: "niri" | "generic";
57
+ alignmentMode?: "layer-shell";
58
+ rendererCardCount?: number;
59
+ rendererCardIds?: string[];
60
+ rendererVisibilityTransitionCount?: number;
61
+ controllerPid?: number;
62
+ panelPid?: number | null;
63
+ panelExpanded?: boolean;
64
+ clickThrough?: boolean;
65
+ panelMoveCount?: number;
66
+ panelActivationCount?: number;
67
+ panelRestartCount?: number;
68
+ lastMovedCardId?: string | null;
48
69
  warnings?: string[];
49
70
  error?: string | null;
50
71
  }
@@ -65,6 +86,7 @@ export interface BrokerClientOptions {
65
86
 
66
87
  export interface SessionStoreOptions {
67
88
  staleAfterMs?: number;
89
+ maxSessions?: number;
68
90
  }
69
91
 
70
92
  export interface ActivityStripBrokerOptions {
@@ -76,7 +98,8 @@ export interface ActivityStripBrokerOptions {
76
98
  remove(sessionId: string, publisherId?: string): boolean;
77
99
  };
78
100
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
79
- focusSession?: (sessionId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
101
+ focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
102
+ focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
80
103
  }
81
104
 
82
105
  export interface ToolCallDescription {
@@ -143,6 +166,21 @@ export interface SessionTelemetryOptions {
143
166
  pi?: TelemetryPiLike;
144
167
  cwd?: string;
145
168
  sessionName?: string;
169
+ env?: NodeJS.ProcessEnv;
170
+ stdinIsTTY?: boolean;
171
+ resolveTerminalIdentity?: (options: {
172
+ env?: NodeJS.ProcessEnv;
173
+ hasUI?: boolean;
174
+ stdinIsTTY?: boolean;
175
+ ttyPath?: string;
176
+ processId?: number;
177
+ ancestorExecutable?: string;
178
+ }) => {
179
+ terminalKind: "ghostty-surface" | "unbound";
180
+ terminalKey: string;
181
+ terminalFamily: string;
182
+ terminalSurfaceId: string;
183
+ };
146
184
  transport?: {
147
185
  publish: (session: SessionSnapshot) => Promise<unknown>;
148
186
  remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
@@ -151,11 +189,11 @@ export interface SessionTelemetryOptions {
151
189
 
152
190
  export interface ActivityStripCompatibilityReport {
153
191
  ok: boolean;
192
+ backend: string;
154
193
  displayServer: "wayland" | "x11" | "headless" | "unknown";
155
194
  windowManager: string | null;
156
- electronPath: string | null;
157
195
  displayCount: number | null;
158
- alignmentMode: "niri" | "generic";
196
+ alignmentMode: "layer-shell";
159
197
  primaryDisplayOnly: boolean;
160
198
  clickThroughDefault: boolean;
161
199
  blockers: string[];
@@ -9,7 +9,16 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
9
9
  export interface SessionSnapshot {
10
10
  sessionId: string;
11
11
  publisherId: string;
12
+ publisherSequence: number;
12
13
  processId: number;
14
+ terminalKind: "ghostty-surface" | "unbound";
15
+ terminalKey: string;
16
+ terminalFamily: string;
17
+ terminalSurfaceId: string;
18
+ cardId?: string;
19
+ publisherCount?: number;
20
+ publisherIds?: string[];
21
+ publisherRecordKeys?: string[];
13
22
  cwd: string;
14
23
  repoLabel: string;
15
24
  sessionName: string;
@@ -38,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
38
47
 
39
48
  export interface ActivityStripRuntimeStatus {
40
49
  state: ActivityStripRuntimeState;
50
+ backend?: "native-layer-shell";
41
51
  startedAt: number;
42
52
  readyAt?: number | null;
43
53
  windowVisible?: boolean;
44
54
  displayServer?: "wayland" | "x11" | "headless" | "unknown";
45
55
  windowManager?: string | null;
46
56
  displayCount?: number | null;
47
- alignmentMode?: "niri" | "generic";
57
+ alignmentMode?: "layer-shell";
58
+ rendererCardCount?: number;
59
+ rendererCardIds?: string[];
60
+ rendererVisibilityTransitionCount?: number;
61
+ controllerPid?: number;
62
+ panelPid?: number | null;
63
+ panelExpanded?: boolean;
64
+ clickThrough?: boolean;
65
+ panelMoveCount?: number;
66
+ panelActivationCount?: number;
67
+ panelRestartCount?: number;
68
+ lastMovedCardId?: string | null;
48
69
  warnings?: string[];
49
70
  error?: string | null;
50
71
  }
@@ -65,6 +86,7 @@ export interface BrokerClientOptions {
65
86
 
66
87
  export interface SessionStoreOptions {
67
88
  staleAfterMs?: number;
89
+ maxSessions?: number;
68
90
  }
69
91
 
70
92
  export interface ActivityStripBrokerOptions {
@@ -76,7 +98,8 @@ export interface ActivityStripBrokerOptions {
76
98
  remove(sessionId: string, publisherId?: string): boolean;
77
99
  };
78
100
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
79
- focusSession?: (sessionId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
101
+ focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
102
+ focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
80
103
  }
81
104
 
82
105
  export interface ToolCallDescription {
@@ -143,6 +166,21 @@ export interface SessionTelemetryOptions {
143
166
  pi?: TelemetryPiLike;
144
167
  cwd?: string;
145
168
  sessionName?: string;
169
+ env?: NodeJS.ProcessEnv;
170
+ stdinIsTTY?: boolean;
171
+ resolveTerminalIdentity?: (options: {
172
+ env?: NodeJS.ProcessEnv;
173
+ hasUI?: boolean;
174
+ stdinIsTTY?: boolean;
175
+ ttyPath?: string;
176
+ processId?: number;
177
+ ancestorExecutable?: string;
178
+ }) => {
179
+ terminalKind: "ghostty-surface" | "unbound";
180
+ terminalKey: string;
181
+ terminalFamily: string;
182
+ terminalSurfaceId: string;
183
+ };
146
184
  transport?: {
147
185
  publish: (session: SessionSnapshot) => Promise<unknown>;
148
186
  remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
@@ -151,11 +189,11 @@ export interface SessionTelemetryOptions {
151
189
 
152
190
  export interface ActivityStripCompatibilityReport {
153
191
  ok: boolean;
192
+ backend: string;
154
193
  displayServer: "wayland" | "x11" | "headless" | "unknown";
155
194
  windowManager: string | null;
156
- electronPath: string | null;
157
195
  displayCount: number | null;
158
- alignmentMode: "niri" | "generic";
196
+ alignmentMode: "layer-shell";
159
197
  primaryDisplayOnly: boolean;
160
198
  clickThroughDefault: boolean;
161
199
  blockers: string[];
@@ -6,6 +6,13 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
10
+ import {
11
+ appIdForGhosttyFamily,
12
+ canonicalGhosttyTerminalKey,
13
+ normalizeGhosttySurfaceId,
14
+ terminalTitleSegment,
15
+ } from "./terminal-identity.mjs";
9
16
 
10
17
  const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
11
18
  const PI_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -92,11 +99,15 @@ export function shortSessionId(value, hexLength = SESSION_TITLE_TOKEN_HEX_LENGTH
92
99
  .toLowerCase();
93
100
  }
94
101
 
95
- /** @param {Array<Record<string, unknown>>} sessions @param {string} sessionId */
96
- export function resolveSnapshotSession(sessions, sessionId) {
97
- const requestedId = String(sessionId ?? "");
98
- const matches = sessions.filter((session) => session?.sessionId === requestedId);
99
- return matches.length === 1 ? matches[0] : null;
102
+ /** @param {Array<Record<string, unknown>>} sessions @param {string} targetId */
103
+ export function resolveSnapshotSession(sessions, targetId) {
104
+ const requestedId = String(targetId ?? "");
105
+ const cardMatches = sessions.filter(
106
+ (session) => session?.cardId === requestedId || sessionCardId(session) === requestedId,
107
+ );
108
+ if (cardMatches.length === 1) return cardMatches[0];
109
+ const logicalMatches = sessions.filter((session) => session?.sessionId === requestedId);
110
+ return logicalMatches.length === 1 ? logicalMatches[0] : null;
100
111
  }
101
112
 
102
113
  /**
@@ -105,8 +116,9 @@ export function resolveSnapshotSession(sessions, sessionId) {
105
116
  * must also identify a known Ghostty build, and ambiguity always returns no match.
106
117
  * @param {Array<Record<string, unknown>>} windows
107
118
  * @param {string} sessionId
119
+ * @param {Record<string, unknown>} [session]
108
120
  */
109
- export function resolveExactGhosttyWindow(windows, sessionId) {
121
+ export function resolveExactGhosttyWindow(windows, sessionId, session = {}) {
110
122
  const fullId = String(sessionId ?? "").trim();
111
123
  if (!PI_SESSION_ID.test(fullId)) return null;
112
124
 
@@ -123,6 +135,22 @@ export function resolveExactGhosttyWindow(windows, sessionId) {
123
135
  };
124
136
 
125
137
  const currentToken = shortSessionId(fullId);
138
+ if (session.terminalKind === "ghostty-surface" && !canonicalGhosttyTerminalKey(session)) {
139
+ return null;
140
+ }
141
+ const surfaceId = normalizeGhosttySurfaceId(session.terminalSurfaceId);
142
+ const titleSegment = terminalTitleSegment(session);
143
+ const expectedAppId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
144
+ if (surfaceId && titleSegment && expectedAppId) {
145
+ const surfaceSuffix = ` · ${titleSegment} · ${currentToken}`;
146
+ const surfaceMatches = windows.filter(
147
+ (window) =>
148
+ Number.isInteger(window?.id) &&
149
+ String(window?.app_id ?? "") === expectedAppId &&
150
+ String(window?.title ?? "").endsWith(surfaceSuffix),
151
+ );
152
+ return surfaceMatches.length === 1 ? surfaceMatches[0] : null;
153
+ }
126
154
  const currentMatches = matchesForToken(currentToken);
127
155
  if (currentMatches.length > 0) return currentMatches.length === 1 ? currentMatches[0] : null;
128
156
 
@@ -153,16 +181,15 @@ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {})
153
181
  const matches = sessions.filter((session) => {
154
182
  const sessionId = resolvePiSessionIdentity(session, options);
155
183
  if (!sessionId) return false;
156
- return resolveExactGhosttyWindow(windows, sessionId)?.id === focusedWindow.id;
184
+ return resolveExactGhosttyWindow(windows, sessionId, session)?.id === focusedWindow.id;
157
185
  });
158
186
  return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
159
187
  }
160
188
 
161
189
  /**
162
190
  * Project the global broker snapshot onto one exact Niri workspace. Membership
163
- * requires an exact, unique Ghostty window; two telemetry records resolving to
164
- * the same window are both excluded rather than guessed. Activity state does
165
- * not affect membership.
191
+ * requires an exact Ghostty window. Multiple publisher streams bound to the same terminal are
192
+ * aggregated into one stable card; ambiguity between distinct windows still fails closed.
166
193
  * @param {Array<Record<string, unknown>>} windows
167
194
  * @param {Record<string, unknown>} workspace
168
195
  * @param {Array<Record<string, unknown>>} sessions
@@ -170,26 +197,57 @@ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {})
170
197
  */
171
198
  export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
172
199
  if (!Number.isInteger(workspace?.id)) return null;
173
- /** @type {Array<{session: Record<string, unknown>; window: Record<string, unknown>}>} */
200
+ /** @type {Array<{cardId: string; session: Record<string, unknown>; window: Record<string, unknown>}>} */
174
201
  const candidates = [];
175
202
  for (const session of sessions) {
176
203
  const sessionIdentity = resolvePiSessionIdentity(session, options);
177
204
  if (!sessionIdentity) continue;
178
- const window = resolveExactGhosttyWindow(windows, sessionIdentity);
205
+ const window = resolveExactGhosttyWindow(windows, sessionIdentity, session);
179
206
  if (!window || window.workspace_id !== workspace.id) continue;
180
- candidates.push({ session, window });
207
+ const cardId = sessionCardId(session);
208
+ if (!cardId) continue;
209
+ candidates.push({ cardId, session: { ...session, cardId }, window });
210
+ }
211
+
212
+ const cardIdsByWindow = new Map();
213
+ for (const candidate of candidates) {
214
+ const ids = cardIdsByWindow.get(candidate.window.id) ?? new Set();
215
+ ids.add(candidate.cardId);
216
+ cardIdsByWindow.set(candidate.window.id, ids);
181
217
  }
182
- const windowCounts = new Map();
218
+ const allowedCardByWindow = new Map();
219
+ for (const [windowId, ids] of cardIdsByWindow) {
220
+ const terminalIds = [...ids].filter((cardId) => cardId.startsWith("terminal:"));
221
+ if (terminalIds.length === 1) allowedCardByWindow.set(windowId, terminalIds[0]);
222
+ else if (ids.size === 1) allowedCardByWindow.set(windowId, [...ids][0]);
223
+ }
224
+
225
+ /** @type {Map<string, Array<{session: Record<string, unknown>; window: Record<string, unknown>}>>} */
226
+ const groups = new Map();
183
227
  for (const candidate of candidates) {
184
- windowCounts.set(candidate.window.id, (windowCounts.get(candidate.window.id) ?? 0) + 1);
228
+ if (allowedCardByWindow.get(candidate.window.id) !== candidate.cardId) continue;
229
+ const group = groups.get(candidate.cardId) ?? [];
230
+ group.push(candidate);
231
+ groups.set(candidate.cardId, group);
185
232
  }
186
- const projectedSessions = candidates
187
- .filter((candidate) => windowCounts.get(candidate.window.id) === 1)
188
- .map((candidate) => candidate.session);
233
+
234
+ const projectedSessions = [];
235
+ for (const [cardId, group] of groups) {
236
+ const windowIds = new Set(group.map((candidate) => candidate.window.id));
237
+ if (windowIds.size !== 1) continue;
238
+ const [card] = projectSessionCards(group.map((candidate) => candidate.session));
239
+ if (!card) continue;
240
+ const window = group[0]?.window;
241
+ projectedSessions.push({ ...card, cardId, windowId: window?.id });
242
+ }
243
+ const focusedCard = projectedSessions.find((session) =>
244
+ windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
245
+ );
189
246
  return {
190
247
  workspace,
191
248
  sessions: projectedSessions,
192
- focusedSessionId: resolveFocusedSnapshotSessionId(windows, projectedSessions, options),
249
+ focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
250
+ focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
193
251
  };
194
252
  }
195
253
 
@@ -216,65 +274,6 @@ export function resolveFocusedNiriWorkspace(workspaces) {
216
274
  return matches.length === 1 ? matches[0] : null;
217
275
  }
218
276
 
219
- /**
220
- * @param {Array<Record<string, unknown>>} windows
221
- * @param {number | null} [workspaceId]
222
- */
223
- export function resolveActivityStripWindow(windows, workspaceId = null) {
224
- const matches = windows.filter(
225
- (window) =>
226
- Number.isInteger(window?.id) &&
227
- window?.title === "Pi Activity Strip" &&
228
- (workspaceId == null || window?.workspace_id === workspaceId),
229
- );
230
- return matches.length === 1 ? matches[0] : null;
231
- }
232
-
233
- /**
234
- * Focus the unique strip already resident on the focused workspace. This command is
235
- * designed for a compositor key binding and never moves a strip across workspaces.
236
- * @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
237
- * @param {NodeJS.ProcessEnv} [env]
238
- * @param {Array<unknown>} [sessions]
239
- */
240
- export async function focusNiriStrip(execFileAsync, env = process.env, sessions = []) {
241
- if (!env.NIRI_SOCKET)
242
- return { ok: false, error: "Niri is not available; strip focus did nothing." };
243
- try {
244
- const [{ stdout: windowOutput }, { stdout: workspaceOutput }] = await Promise.all([
245
- execFileAsync("niri", ["msg", "-j", "windows"], { env }),
246
- execFileAsync("niri", ["msg", "-j", "workspaces"], { env }),
247
- ]);
248
- const windows = JSON.parse(String(windowOutput ?? "[]"));
249
- const workspaces = JSON.parse(String(workspaceOutput ?? "[]"));
250
- if (!Array.isArray(windows) || !Array.isArray(workspaces))
251
- throw new Error("unexpected payload");
252
- const focusedWorkspace = resolveFocusedNiriWorkspace(workspaces);
253
- const strip = focusedWorkspace
254
- ? resolveActivityStripWindow(windows, Number(focusedWorkspace.id))
255
- : null;
256
- const sessionRecords = Array.isArray(sessions)
257
- ? /** @type {Array<Record<string, unknown>>} */ (sessions)
258
- : [];
259
- const view = resolveFocusedWorkspaceView(windows, workspaces, sessionRecords, { env });
260
- if (!strip || !view?.sessions.length) {
261
- return {
262
- ok: false,
263
- error: "No visible resident strip with exact tracked sessions exists here; nothing moved.",
264
- };
265
- }
266
- await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(strip.id)], {
267
- env,
268
- });
269
- return { ok: true, windowId: strip.id };
270
- } catch {
271
- return {
272
- ok: false,
273
- error: "Could not focus the strip through Niri; nothing else was focused.",
274
- };
275
- }
276
- }
277
-
278
277
  /**
279
278
  * @param {string | Record<string, unknown>} session
280
279
  * @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
@@ -304,7 +303,8 @@ export async function focusNiriSession(session, execFileAsync, env = process.env
304
303
  if (!Array.isArray(windows)) {
305
304
  return { ok: false, error: "Unexpected Niri window data; focus did nothing." };
306
305
  }
307
- const target = resolveExactGhosttyWindow(windows, sessionId);
306
+ const record = session && typeof session === "object" ? session : {};
307
+ const target = resolveExactGhosttyWindow(windows, sessionId, record);
308
308
  if (!target) {
309
309
  return {
310
310
  ok: false,