@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
@@ -8,7 +8,17 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
8
8
 
9
9
  export interface SessionSnapshot {
10
10
  sessionId: string;
11
+ publisherId: string;
12
+ publisherSequence: number;
11
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[];
12
22
  cwd: string;
13
23
  repoLabel: string;
14
24
  sessionName: string;
@@ -20,6 +30,7 @@ export interface SessionSnapshot {
20
30
  state: SessionState;
21
31
  turnIndex: number;
22
32
  updatedAt: number;
33
+ lastEventAt: number;
23
34
  startedAt: number;
24
35
  agentStartedAt: number | null;
25
36
  agentActive: boolean;
@@ -36,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
36
47
 
37
48
  export interface ActivityStripRuntimeStatus {
38
49
  state: ActivityStripRuntimeState;
50
+ backend?: "native-layer-shell";
39
51
  startedAt: number;
40
52
  readyAt?: number | null;
41
53
  windowVisible?: boolean;
42
54
  displayServer?: "wayland" | "x11" | "headless" | "unknown";
43
55
  windowManager?: string | null;
44
56
  displayCount?: number | null;
45
- 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;
46
69
  warnings?: string[];
47
70
  error?: string | null;
48
71
  }
@@ -63,6 +86,7 @@ export interface BrokerClientOptions {
63
86
 
64
87
  export interface SessionStoreOptions {
65
88
  staleAfterMs?: number;
89
+ maxSessions?: number;
66
90
  }
67
91
 
68
92
  export interface ActivityStripBrokerOptions {
@@ -71,10 +95,11 @@ export interface ActivityStripBrokerOptions {
71
95
  store?: {
72
96
  snapshot(): BrokerSnapshot;
73
97
  upsert(session: Partial<SessionSnapshot> | Record<string, unknown>): boolean;
74
- remove(sessionId: string): boolean;
98
+ remove(sessionId: string, publisherId?: string): boolean;
75
99
  };
76
100
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
77
- 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 }>;
78
103
  }
79
104
 
80
105
  export interface ToolCallDescription {
@@ -115,6 +140,11 @@ export interface TurnStartEventLike {
115
140
  turnIndex?: number;
116
141
  }
117
142
 
143
+ export interface TurnEndEventLike {
144
+ turnIndex?: number;
145
+ message?: unknown;
146
+ }
147
+
118
148
  export interface AssistantMessageEventLike {
119
149
  type?: string;
120
150
  delta?: unknown;
@@ -136,15 +166,34 @@ export interface SessionTelemetryOptions {
136
166
  pi?: TelemetryPiLike;
137
167
  cwd?: string;
138
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
+ };
184
+ transport?: {
185
+ publish: (session: SessionSnapshot) => Promise<unknown>;
186
+ remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
187
+ };
139
188
  }
140
189
 
141
190
  export interface ActivityStripCompatibilityReport {
142
191
  ok: boolean;
192
+ backend: string;
143
193
  displayServer: "wayland" | "x11" | "headless" | "unknown";
144
194
  windowManager: string | null;
145
- electronPath: string | null;
146
195
  displayCount: number | null;
147
- alignmentMode: "niri" | "generic";
196
+ alignmentMode: "layer-shell";
148
197
  primaryDisplayOnly: boolean;
149
198
  clickThroughDefault: boolean;
150
199
  blockers: string[];
@@ -8,7 +8,17 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
8
8
 
9
9
  export interface SessionSnapshot {
10
10
  sessionId: string;
11
+ publisherId: string;
12
+ publisherSequence: number;
11
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[];
12
22
  cwd: string;
13
23
  repoLabel: string;
14
24
  sessionName: string;
@@ -20,6 +30,7 @@ export interface SessionSnapshot {
20
30
  state: SessionState;
21
31
  turnIndex: number;
22
32
  updatedAt: number;
33
+ lastEventAt: number;
23
34
  startedAt: number;
24
35
  agentStartedAt: number | null;
25
36
  agentActive: boolean;
@@ -36,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
36
47
 
37
48
  export interface ActivityStripRuntimeStatus {
38
49
  state: ActivityStripRuntimeState;
50
+ backend?: "native-layer-shell";
39
51
  startedAt: number;
40
52
  readyAt?: number | null;
41
53
  windowVisible?: boolean;
42
54
  displayServer?: "wayland" | "x11" | "headless" | "unknown";
43
55
  windowManager?: string | null;
44
56
  displayCount?: number | null;
45
- 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;
46
69
  warnings?: string[];
47
70
  error?: string | null;
48
71
  }
@@ -63,6 +86,7 @@ export interface BrokerClientOptions {
63
86
 
64
87
  export interface SessionStoreOptions {
65
88
  staleAfterMs?: number;
89
+ maxSessions?: number;
66
90
  }
67
91
 
68
92
  export interface ActivityStripBrokerOptions {
@@ -71,10 +95,11 @@ export interface ActivityStripBrokerOptions {
71
95
  store?: {
72
96
  snapshot(): BrokerSnapshot;
73
97
  upsert(session: Partial<SessionSnapshot> | Record<string, unknown>): boolean;
74
- remove(sessionId: string): boolean;
98
+ remove(sessionId: string, publisherId?: string): boolean;
75
99
  };
76
100
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
77
- 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 }>;
78
103
  }
79
104
 
80
105
  export interface ToolCallDescription {
@@ -115,6 +140,11 @@ export interface TurnStartEventLike {
115
140
  turnIndex?: number;
116
141
  }
117
142
 
143
+ export interface TurnEndEventLike {
144
+ turnIndex?: number;
145
+ message?: unknown;
146
+ }
147
+
118
148
  export interface AssistantMessageEventLike {
119
149
  type?: string;
120
150
  delta?: unknown;
@@ -136,15 +166,34 @@ export interface SessionTelemetryOptions {
136
166
  pi?: TelemetryPiLike;
137
167
  cwd?: string;
138
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
+ };
184
+ transport?: {
185
+ publish: (session: SessionSnapshot) => Promise<unknown>;
186
+ remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
187
+ };
139
188
  }
140
189
 
141
190
  export interface ActivityStripCompatibilityReport {
142
191
  ok: boolean;
192
+ backend: string;
143
193
  displayServer: "wayland" | "x11" | "headless" | "unknown";
144
194
  windowManager: string | null;
145
- electronPath: string | null;
146
195
  displayCount: number | null;
147
- alignmentMode: "niri" | "generic";
196
+ alignmentMode: "layer-shell";
148
197
  primaryDisplayOnly: boolean;
149
198
  clickThroughDefault: boolean;
150
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,
@@ -5,6 +5,7 @@
5
5
  // ---
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
+ import { canonicalGhosttyTerminalKey, normalizeGhosttySurfaceId } from "./terminal-identity.mjs";
8
9
 
9
10
  /** @typedef {import("./contracts.ts").SessionState} SessionState */
10
11
  /** @typedef {import("./contracts.ts").SessionSnapshot} SessionSnapshot */
@@ -12,6 +13,21 @@ import { randomUUID } from "node:crypto";
12
13
  /** @type {Set<SessionState>} */
13
14
  const SESSION_STATES = new Set(["idle", "thinking", "tool", "waiting", "success", "error"]);
14
15
 
16
+ const MAX_CLOCK_SKEW_MS = 60_000;
17
+
18
+ /** @param {unknown} value @param {number} maxLength */
19
+ function boundedString(value, maxLength) {
20
+ return String(value ?? "").slice(0, maxLength);
21
+ }
22
+
23
+ /** @param {unknown} value @param {number} fallback @param {number} now */
24
+ function finiteTimestamp(value, fallback, now) {
25
+ const timestamp = Number(value);
26
+ return Number.isFinite(timestamp) && timestamp >= 0 && timestamp <= now + MAX_CLOCK_SKEW_MS
27
+ ? timestamp
28
+ : fallback;
29
+ }
30
+
15
31
  /** @param {string} type @param {Record<string, unknown>} [payload] */
16
32
  export function makeMessage(type, payload = {}) {
17
33
  return {
@@ -23,36 +39,55 @@ export function makeMessage(type, payload = {}) {
23
39
 
24
40
  /** @param {Partial<SessionSnapshot> | Record<string, unknown>} [session] @returns {SessionSnapshot} */
25
41
  export function normalizeSessionSnapshot(session = {}) {
42
+ if (!session || typeof session !== "object") session = {};
26
43
  const now = Date.now();
27
- const updatedAt = Number(session.updatedAt ?? now) || now;
28
- const startedAt = Number(session.startedAt ?? updatedAt) || updatedAt;
44
+ const updatedAt = finiteTimestamp(session.updatedAt, now, now);
45
+ const lastEventAt = finiteTimestamp(session.lastEventAt, updatedAt, now);
46
+ const startedAt = finiteTimestamp(session.startedAt, updatedAt, now);
47
+ const normalizedAgentStartedAt = finiteTimestamp(session.agentStartedAt, 0, now);
29
48
  const agentStartedAt =
30
- session.agentStartedAt == null ? null : Number(session.agentStartedAt) || null;
49
+ session.agentStartedAt == null || !normalizedAgentStartedAt ? null : normalizedAgentStartedAt;
31
50
  const rawState = typeof session.state === "string" ? session.state : "idle";
32
51
  /** @type {SessionState} */
33
52
  const state = SESSION_STATES.has(/** @type {SessionState} */ (rawState))
34
53
  ? /** @type {SessionState} */ (rawState)
35
54
  : "idle";
36
55
 
56
+ const terminalKey = canonicalGhosttyTerminalKey(session);
57
+ const terminalKind = terminalKey ? "ghostty-surface" : "unbound";
58
+ const terminalFamily = terminalKey ? String(session.terminalFamily) : "";
59
+ const terminalSurfaceId = terminalKey ? normalizeGhosttySurfaceId(session.terminalSurfaceId) : "";
60
+ const rawPublisherSequence = Number(session.publisherSequence ?? 0);
61
+ const publisherSequence =
62
+ Number.isSafeInteger(rawPublisherSequence) && rawPublisherSequence >= 0
63
+ ? rawPublisherSequence
64
+ : 0;
37
65
  return {
38
- sessionId: String(session.sessionId ?? ""),
66
+ sessionId: boundedString(session.sessionId, 256),
67
+ publisherId: boundedString(session.publisherId, 256),
68
+ publisherSequence,
39
69
  processId: Number(session.processId ?? 0) || 0,
40
- cwd: String(session.cwd ?? ""),
41
- repoLabel: String(session.repoLabel ?? "pi session"),
42
- sessionName: String(session.sessionName ?? ""),
43
- phase: String(session.phase ?? "Idle"),
44
- detail: String(session.detail ?? "Ready"),
45
- assistantPreview: String(session.assistantPreview ?? ""),
46
- toolName: String(session.toolName ?? ""),
47
- toolTarget: String(session.toolTarget ?? ""),
70
+ terminalKind,
71
+ terminalKey,
72
+ terminalFamily,
73
+ terminalSurfaceId,
74
+ cwd: boundedString(session.cwd, 4096),
75
+ repoLabel: boundedString(session.repoLabel ?? "pi session", 256),
76
+ sessionName: boundedString(session.sessionName, 256),
77
+ phase: boundedString(session.phase ?? "Idle", 512),
78
+ detail: boundedString(session.detail ?? "Ready", 2048),
79
+ assistantPreview: boundedString(session.assistantPreview, 2048),
80
+ toolName: boundedString(session.toolName, 256),
81
+ toolTarget: boundedString(session.toolTarget, 2048),
48
82
  state,
49
83
  turnIndex: Number(session.turnIndex ?? 0) || 0,
50
84
  updatedAt,
85
+ lastEventAt,
51
86
  startedAt,
52
87
  agentStartedAt,
53
88
  agentActive: Boolean(session.agentActive),
54
- lastPromptPreview: String(session.lastPromptPreview ?? ""),
55
- errorMessage: String(session.errorMessage ?? ""),
89
+ lastPromptPreview: boundedString(session.lastPromptPreview, 2048),
90
+ errorMessage: boundedString(session.errorMessage, 2048),
56
91
  };
57
92
  }
58
93
 
@@ -0,0 +1,102 @@
1
+ // ---
2
+ // summary: "projects publisher snapshots into stable operator-facing terminal cards"
3
+ // read_when:
4
+ // - "changing publisher aggregation, card identity, or membership comparison"
5
+ // ---
6
+
7
+ import { isActiveSession } from "./activity-order.mjs";
8
+ import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
9
+
10
+ /** @typedef {import("./contracts.ts").SessionSnapshot} SessionSnapshot */
11
+ /** @typedef {Partial<SessionSnapshot> & Record<string, unknown>} SessionLike */
12
+
13
+ /** @param {SessionLike} session */
14
+ export function sessionRecordKey(session) {
15
+ const sessionId = String(session?.sessionId ?? "");
16
+ const publisherId = String(session?.publisherId ?? "");
17
+ return publisherId ? `${sessionId}|${publisherId}` : sessionId;
18
+ }
19
+
20
+ /** @param {SessionLike} session */
21
+ export function sessionCardId(session) {
22
+ const terminalKey = canonicalGhosttyTerminalKey(session);
23
+ if (terminalKey) return `terminal:${terminalKey}`;
24
+ const sessionId = String(session?.sessionId ?? "").trim();
25
+ return sessionId ? `session:${sessionId}` : "";
26
+ }
27
+
28
+ /** @param {SessionLike} session */
29
+ export function sessionMembershipKey(session) {
30
+ return `${sessionRecordKey(session)}→${sessionCardId(session)}`;
31
+ }
32
+
33
+ /** @param {SessionLike} session */
34
+ function attentionRank(session) {
35
+ if (isActiveSession(session)) return 3;
36
+ if (session?.state === "error") return 2;
37
+ if (session?.state === "success") return 1;
38
+ return 0;
39
+ }
40
+
41
+ /** @param {SessionLike} left @param {SessionLike} right */
42
+ export function comparePublisherRepresentatives(left, right) {
43
+ const rankDelta = attentionRank(right) - attentionRank(left);
44
+ if (rankDelta) return rankDelta;
45
+ const eventDelta = Number(right?.lastEventAt ?? 0) - Number(left?.lastEventAt ?? 0);
46
+ if (eventDelta) return eventDelta;
47
+ const turnDelta = Number(right?.turnIndex ?? 0) - Number(left?.turnIndex ?? 0);
48
+ if (turnDelta) return turnDelta;
49
+ const startDelta = Number(right?.startedAt ?? 0) - Number(left?.startedAt ?? 0);
50
+ if (startDelta) return startDelta;
51
+ return sessionRecordKey(left).localeCompare(sessionRecordKey(right));
52
+ }
53
+
54
+ /**
55
+ * @param {SessionLike[]} [sessions]
56
+ * @param {Set<string> | null} [allowedCardIds]
57
+ * @returns {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]}>}
58
+ */
59
+ export function projectSessionCards(sessions = [], allowedCardIds = null) {
60
+ /** @type {Map<string, SessionLike[]>} */
61
+ const groups = new Map();
62
+ for (const session of sessions) {
63
+ const cardId = sessionCardId(session);
64
+ if (!cardId || (allowedCardIds && !allowedCardIds.has(cardId))) continue;
65
+ const group = groups.get(cardId) ?? [];
66
+ group.push(session);
67
+ groups.set(cardId, group);
68
+ }
69
+
70
+ /** @type {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]}>} */
71
+ const cards = [];
72
+ for (const [cardId, group] of groups) {
73
+ const ordered = [...group].sort(comparePublisherRepresentatives);
74
+ const representative = ordered[0];
75
+ if (!representative) continue;
76
+ cards.push({
77
+ ...representative,
78
+ cardId,
79
+ publisherCount: group.length,
80
+ publisherIds: group.map((session) => String(session.publisherId ?? "")).filter(Boolean),
81
+ publisherRecordKeys: group.map(sessionRecordKey),
82
+ });
83
+ }
84
+ return cards;
85
+ }
86
+
87
+ /** @param {SessionLike[]} [left] @param {SessionLike[]} [right] */
88
+ export function haveSameRecordMembership(left = [], right = []) {
89
+ if (left.length !== right.length) return false;
90
+ const leftKeys = left.map(sessionMembershipKey).sort();
91
+ const rightKeys = right.map(sessionMembershipKey).sort();
92
+ return leftKeys.every((key, index) => key === rightKeys[index]);
93
+ }
94
+
95
+ /** @param {SessionLike[]} [left] @param {SessionLike[]} [right] */
96
+ export function haveSameCardMembership(left = [], right = []) {
97
+ const leftKeys = [...new Set(left.map(sessionCardId).filter(Boolean))].sort();
98
+ const rightKeys = [...new Set(right.map(sessionCardId).filter(Boolean))].sort();
99
+ return (
100
+ leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
101
+ );
102
+ }