@tryinget/pi-activity-strip 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +136 -157
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +50 -60
  4. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/Cargo.lock +1045 -0
  7. package/native/panel/Cargo.toml +23 -0
  8. package/native/panel/src/app.rs +606 -0
  9. package/native/panel/src/card_view.rs +602 -0
  10. package/native/panel/src/main.rs +31 -0
  11. package/native/panel/src/protocol.rs +297 -0
  12. package/native/panel/src/runtime.rs +151 -0
  13. package/native/panel/src/style.css +250 -0
  14. package/package.json +16 -4
  15. package/src/broker/server.mjs +43 -7
  16. package/src/broker/session-store.mjs +41 -13
  17. package/src/client/broker-client.mjs +10 -3
  18. package/src/client/session-telemetry.mjs +54 -14
  19. package/src/common/activity-order.mjs +15 -9
  20. package/src/common/agent-identity.mjs +163 -0
  21. package/src/common/ak-tasks.mjs +354 -0
  22. package/src/common/alignment-controller.mjs +24 -42
  23. package/src/common/card-display.mjs +5 -0
  24. package/src/common/claude-events.mjs +190 -0
  25. package/src/common/claude-hook-config.mjs +56 -0
  26. package/src/common/claude-transcript.mjs +163 -0
  27. package/src/common/codex-transcript.mjs +153 -0
  28. package/src/common/compatibility.mjs +28 -13
  29. package/src/common/constants.mjs +10 -28
  30. package/src/common/contracts.d.ts +68 -4
  31. package/src/common/contracts.ts +105 -4
  32. package/src/common/ghostty-present.mjs +100 -0
  33. package/src/common/ghostty-theme.mjs +149 -0
  34. package/src/common/niri-focus.mjs +209 -140
  35. package/src/common/protocol.mjs +48 -16
  36. package/src/common/session-cards.mjs +105 -0
  37. package/src/common/status-report.mjs +53 -1
  38. package/src/common/surface-bindings.mjs +234 -0
  39. package/src/common/telemetry.mjs +8 -1
  40. package/src/common/terminal-identity.mjs +212 -0
  41. package/src/common/window-placement.mjs +59 -0
  42. package/src/common/workspace-view.mjs +161 -0
  43. package/src/native/agent-discovery.mjs +294 -0
  44. package/src/native/ak-runtime.mjs +164 -0
  45. package/src/native/codex-discovery.mjs +128 -0
  46. package/src/native/ghostty-tab-inventory.py +99 -0
  47. package/src/native/height-repair.mjs +154 -0
  48. package/src/native/main.mjs +499 -0
  49. package/src/native/panel-projection.mjs +161 -0
  50. package/src/native/placement.mjs +225 -0
  51. package/src/native/tab-inventory.mjs +188 -0
  52. package/src/native/theme-runtime.mjs +72 -0
  53. package/src/native/theme-source.mjs +197 -0
  54. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
  55. package/src/common/electron.mjs +0 -57
  56. package/src/electron/main.mjs +0 -499
  57. package/src/electron/niri-native-window-runtime.mjs +0 -178
  58. package/src/electron/preload.cjs +0 -50
  59. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  60. package/src/electron/workspace-view-runtime.mjs +0 -360
  61. package/src/ui/strip-html.mjs +0 -500
@@ -6,12 +6,34 @@
6
6
 
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import { presentGhosttySurface } from "./ghostty-present.mjs";
10
+ import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
11
+ import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
12
+ import {
13
+ appIdForGhosttyFamily,
14
+ canonicalGhosttyTerminalKey,
15
+ normalizeGhosttySurfaceId,
16
+ terminalTitleSegment,
17
+ } from "./terminal-identity.mjs";
18
+ import { resolveHiddenSurfaceWindow } from "./window-placement.mjs";
9
19
 
10
20
  const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
11
21
  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;
12
22
  const SESSION_PRESENCE_SOURCE = "@tryinget/pi-little-helpers/session-presence";
13
23
  const SESSION_TITLE_TOKEN_HEX_LENGTH = 32;
14
24
  const LEGACY_SESSION_TITLE_TOKEN_HEX_LENGTH = 8;
25
+ const PRESENT_VERIFY_ATTEMPTS = 4;
26
+ const PRESENT_VERIFY_DELAY_MS = 120;
27
+
28
+ /** @typedef {import("./window-placement.mjs").PlacementOptions} PlacementOptions */
29
+ /** @typedef {import("./window-placement.mjs").WindowPlacement} WindowPlacement */
30
+ /**
31
+ * @typedef {PlacementOptions & {
32
+ * env?: NodeJS.ProcessEnv;
33
+ * readFileSync?: typeof fs.readFileSync;
34
+ * existsSync?: typeof fs.existsSync;
35
+ * }} ResolveOptions
36
+ */
15
37
 
16
38
  /**
17
39
  * Read one bounded Niri JSON list. Polling callers treat command, timeout,
@@ -92,11 +114,15 @@ export function shortSessionId(value, hexLength = SESSION_TITLE_TOKEN_HEX_LENGTH
92
114
  .toLowerCase();
93
115
  }
94
116
 
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;
117
+ /** @param {Array<Record<string, unknown>>} sessions @param {string} targetId */
118
+ export function resolveSnapshotSession(sessions, targetId) {
119
+ const requestedId = String(targetId ?? "");
120
+ const cardMatches = sessions.filter(
121
+ (session) => session?.cardId === requestedId || sessionCardId(session) === requestedId,
122
+ );
123
+ if (cardMatches.length === 1) return cardMatches[0];
124
+ const logicalMatches = sessions.filter((session) => session?.sessionId === requestedId);
125
+ return logicalMatches.length === 1 ? logicalMatches[0] : null;
100
126
  }
101
127
 
102
128
  /**
@@ -105,8 +131,9 @@ export function resolveSnapshotSession(sessions, sessionId) {
105
131
  * must also identify a known Ghostty build, and ambiguity always returns no match.
106
132
  * @param {Array<Record<string, unknown>>} windows
107
133
  * @param {string} sessionId
134
+ * @param {Record<string, unknown>} [session]
108
135
  */
109
- export function resolveExactGhosttyWindow(windows, sessionId) {
136
+ export function resolveExactGhosttyWindow(windows, sessionId, session = {}) {
110
137
  const fullId = String(sessionId ?? "").trim();
111
138
  if (!PI_SESSION_ID.test(fullId)) return null;
112
139
 
@@ -123,6 +150,22 @@ export function resolveExactGhosttyWindow(windows, sessionId) {
123
150
  };
124
151
 
125
152
  const currentToken = shortSessionId(fullId);
153
+ if (session.terminalKind === "ghostty-surface" && !canonicalGhosttyTerminalKey(session)) {
154
+ return null;
155
+ }
156
+ const surfaceId = normalizeGhosttySurfaceId(session.terminalSurfaceId);
157
+ const titleSegment = terminalTitleSegment(session);
158
+ const expectedAppId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
159
+ if (surfaceId && titleSegment && expectedAppId) {
160
+ const surfaceSuffix = ` · ${titleSegment} · ${currentToken}`;
161
+ const surfaceMatches = windows.filter(
162
+ (window) =>
163
+ Number.isInteger(window?.id) &&
164
+ String(window?.app_id ?? "") === expectedAppId &&
165
+ String(window?.title ?? "").endsWith(surfaceSuffix),
166
+ );
167
+ return surfaceMatches.length === 1 ? surfaceMatches[0] : null;
168
+ }
126
169
  const currentMatches = matchesForToken(currentToken);
127
170
  if (currentMatches.length > 0) return currentMatches.length === 1 ? currentMatches[0] : null;
128
171
 
@@ -139,184 +182,210 @@ export function resolveExactGhosttyWindow(windows, sessionId) {
139
182
  }
140
183
 
141
184
  /**
142
- * Resolve the one snapshot session whose exact Ghostty window currently owns
143
- * compositor focus. Missing, ambiguous, non-Ghostty, and stale identities all
144
- * fail closed so the renderer never highlights a guessed session.
185
+ * Place a session on its window. A visible title is exact proof; a bound surface whose tab is
186
+ * hidden behind another tab is placed through its Ghostty host process and remembered window.
145
187
  * @param {Array<Record<string, unknown>>} windows
146
- * @param {Array<Record<string, unknown>>} sessions
147
- * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
188
+ * @param {string} sessionId
189
+ * @param {Record<string, unknown>} [session]
190
+ * @param {PlacementOptions} [options]
191
+ * @returns {WindowPlacement | null}
148
192
  */
149
- export function resolveFocusedSnapshotSessionId(windows, sessions, options = {}) {
150
- const focusedWindows = windows.filter((window) => window?.is_focused === true);
151
- if (focusedWindows.length !== 1) return null;
152
- const focusedWindow = focusedWindows[0];
153
- const matches = sessions.filter((session) => {
154
- const sessionId = resolvePiSessionIdentity(session, options);
155
- if (!sessionId) return false;
156
- return resolveExactGhosttyWindow(windows, sessionId)?.id === focusedWindow.id;
193
+ export function resolveSessionWindow(windows, sessionId, session = {}, options = {}) {
194
+ const exact = resolveExactGhosttyWindow(windows, sessionId, session);
195
+ if (exact) return { window: exact, placement: "title" };
196
+ return resolveHiddenSurfaceWindow(windows, session, {
197
+ ...options,
198
+ sessionToken: shortSessionId(sessionId),
157
199
  });
158
- return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
159
200
  }
160
201
 
161
202
  /**
162
- * 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.
203
+ * A tab occupied by a coding agent other than Pi carries no Pi identity, so it is placed by the
204
+ * exact title the agent put on its own terminal, and otherwise by host containment and window
205
+ * memory. A title claimed by more than one window is ambiguous and places nothing.
166
206
  * @param {Array<Record<string, unknown>>} windows
167
- * @param {Record<string, unknown>} workspace
168
- * @param {Array<Record<string, unknown>>} sessions
169
- * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
207
+ * @param {Record<string, unknown>} session
208
+ * @param {PlacementOptions} [options]
209
+ * @returns {WindowPlacement | null}
170
210
  */
171
- export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
172
- if (!Number.isInteger(workspace?.id)) return null;
173
- /** @type {Array<{session: Record<string, unknown>; window: Record<string, unknown>}>} */
174
- const candidates = [];
175
- for (const session of sessions) {
176
- const sessionIdentity = resolvePiSessionIdentity(session, options);
177
- if (!sessionIdentity) continue;
178
- const window = resolveExactGhosttyWindow(windows, sessionIdentity);
179
- if (!window || window.workspace_id !== workspace.id) continue;
180
- candidates.push({ session, window });
181
- }
182
- const windowCounts = new Map();
183
- for (const candidate of candidates) {
184
- windowCounts.set(candidate.window.id, (windowCounts.get(candidate.window.id) ?? 0) + 1);
211
+ export function resolveAgentSessionWindow(windows, session, options = {}) {
212
+ const titleSuffix = String(session.titleSuffix ?? "").trim();
213
+ if (titleSuffix) {
214
+ const expectedAppId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
215
+ const matches = windows.filter(
216
+ (window) =>
217
+ Number.isInteger(window?.id) &&
218
+ String(window?.app_id ?? "") === expectedAppId &&
219
+ String(window?.title ?? "").endsWith(titleSuffix),
220
+ );
221
+ if (matches.length === 1) return { window: matches[0], placement: "title" };
222
+ if (matches.length > 1) return null;
185
223
  }
186
- const projectedSessions = candidates
187
- .filter((candidate) => windowCounts.get(candidate.window.id) === 1)
188
- .map((candidate) => candidate.session);
189
- return {
190
- workspace,
191
- sessions: projectedSessions,
192
- focusedSessionId: resolveFocusedSnapshotSessionId(windows, projectedSessions, options),
193
- };
224
+ return resolveHiddenSurfaceWindow(windows, session, options);
194
225
  }
195
226
 
196
227
  /**
197
- * Project the global broker snapshot onto the one focused Niri workspace.
198
- * @param {Array<Record<string, unknown>>} windows
199
- * @param {Array<Record<string, unknown>>} workspaces
200
- * @param {Array<Record<string, unknown>>} sessions
201
- * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
228
+ * Whether a session could ever be placed by a title. Activation can only confirm that a presented
229
+ * tab became visible for these; for anything else the compositor offers no readable proof.
230
+ * @param {Record<string, unknown>} session
231
+ * @param {ResolveOptions} [options]
202
232
  */
203
- export function resolveFocusedWorkspaceView(windows, workspaces, sessions, options = {}) {
204
- const workspace = resolveFocusedNiriWorkspace(workspaces);
205
- return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
206
- }
207
-
208
- /** @param {Array<Record<string, unknown>>} workspaces */
209
- export function resolveFocusedNiriWorkspace(workspaces) {
210
- const matches = workspaces.filter(
211
- (workspace) =>
212
- workspace?.is_focused === true &&
213
- Number.isInteger(workspace?.id) &&
214
- (typeof workspace?.name === "string" || Number.isInteger(workspace?.idx)),
215
- );
216
- return matches.length === 1 ? matches[0] : null;
233
+ export function canVerifyTabVisibility(session, options = {}) {
234
+ if (resolvePiSessionIdentity(session, options)) return true;
235
+ return String(session?.titleSuffix ?? "").trim().length > 0;
217
236
  }
218
237
 
219
238
  /**
239
+ * Place any tracked session: a Pi session by its exact Pi identity, an agent tab by its own title.
220
240
  * @param {Array<Record<string, unknown>>} windows
221
- * @param {number | null} [workspaceId]
241
+ * @param {Record<string, unknown>} session
242
+ * @param {ResolveOptions} [options]
243
+ * @returns {WindowPlacement | null}
222
244
  */
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;
245
+ export function placeSessionWindow(windows, session, options = {}) {
246
+ const piSessionId = resolvePiSessionIdentity(session, options);
247
+ if (piSessionId) return resolveSessionWindow(windows, piSessionId, session, options);
248
+ return canonicalGhosttyTerminalKey(session)
249
+ ? resolveAgentSessionWindow(windows, session, options)
250
+ : null;
231
251
  }
232
252
 
233
253
  /**
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]
254
+ * Resolve the one snapshot session whose exact Ghostty window currently owns
255
+ * compositor focus. Missing, ambiguous, non-Ghostty, and stale identities all
256
+ * fail closed so the renderer never highlights a guessed session.
257
+ * @param {Array<Record<string, unknown>>} windows
258
+ * @param {Array<Record<string, unknown>>} sessions
259
+ * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
239
260
  */
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
- }
261
+ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {}) {
262
+ const focusedWindows = windows.filter((window) => window?.is_focused === true);
263
+ if (focusedWindows.length !== 1) return null;
264
+ const focusedWindow = focusedWindows[0];
265
+ const matches = sessions.filter((session) => {
266
+ const sessionId = resolvePiSessionIdentity(session, options);
267
+ if (!sessionId) return false;
268
+ return resolveExactGhosttyWindow(windows, sessionId, session)?.id === focusedWindow.id;
269
+ });
270
+ return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
276
271
  }
277
272
 
278
273
  /**
274
+ * Focus the exact terminal behind a card. A visible tab needs only compositor focus. A hidden tab
275
+ * is first presented inside its proven Ghostty host process, then its window is focused, and the
276
+ * result counts as success only once the window title proves the tab became visible.
279
277
  * @param {string | Record<string, unknown>} session
280
278
  * @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
281
279
  * @param {NodeJS.ProcessEnv} [env]
282
- * @param {{readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
280
+ * @param {PlacementOptions & {
281
+ * readFileSync?: typeof fs.readFileSync;
282
+ * existsSync?: typeof fs.existsSync;
283
+ * presentSurface?: typeof presentGhosttySurface;
284
+ * sleep?: (milliseconds: number) => Promise<void>;
285
+ * }} [options]
286
+ * @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>}
283
287
  */
284
288
  export async function focusNiriSession(session, execFileAsync, env = process.env, options = {}) {
285
289
  if (!env.NIRI_SOCKET) return { ok: false, error: "Niri is not available; focus did nothing." };
286
- const sessionId = resolvePiSessionIdentity(session, {
287
- env,
288
- readFileSync: options.readFileSync,
289
- existsSync: options.existsSync,
290
- });
291
- if (!sessionId) {
290
+ const record =
291
+ session && typeof session === "object" ? session : { sessionId: String(session ?? "") };
292
+ const placementOptions = { ...options, env };
293
+ if (!resolvePiSessionIdentity(record, placementOptions) && !canonicalGhosttyTerminalKey(record)) {
292
294
  return {
293
295
  ok: false,
294
296
  error: "Exact Pi identity is unavailable; reload that Pi tab and try again.",
295
297
  };
296
298
  }
299
+ const readWindows = async () => {
300
+ const result = await execFileAsync("niri", ["msg", "-j", "windows"], { env });
301
+ const windows = JSON.parse(String(result.stdout ?? "[]"));
302
+ if (!Array.isArray(windows)) throw new Error("Unexpected Niri window data");
303
+ return windows;
304
+ };
297
305
  let windows;
298
306
  try {
299
- const result = await execFileAsync("niri", ["msg", "-j", "windows"], { env });
300
- windows = JSON.parse(String(result.stdout ?? "[]"));
301
- } catch {
302
- return { ok: false, error: "Could not inspect Niri windows; focus did nothing." };
303
- }
304
- if (!Array.isArray(windows)) {
305
- return { ok: false, error: "Unexpected Niri window data; focus did nothing." };
307
+ windows = await readWindows();
308
+ } catch (error) {
309
+ return {
310
+ ok: false,
311
+ error:
312
+ error instanceof Error && error.message === "Unexpected Niri window data"
313
+ ? "Unexpected Niri window data; focus did nothing."
314
+ : "Could not inspect Niri windows; focus did nothing.",
315
+ };
306
316
  }
307
- const target = resolveExactGhosttyWindow(windows, sessionId);
308
- if (!target) {
317
+ const placed = placeSessionWindow(windows, record, placementOptions);
318
+ if (!placed) {
309
319
  return {
310
320
  ok: false,
311
- error: "Pi identity did not resolve to exactly one Ghostty window; focus did nothing.",
321
+ error: "That session did not resolve to exactly one Ghostty window; focus did nothing.",
312
322
  };
313
323
  }
324
+ const target = placed.window;
325
+ const targetId = Number(target.id);
326
+ const focusWindow = () =>
327
+ execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(targetId)], { env });
328
+ if (placed.placement === "title") {
329
+ try {
330
+ await focusWindow();
331
+ return { ok: true, windowId: targetId };
332
+ } catch {
333
+ return { ok: false, error: "Niri rejected the exact focus request; focus did nothing." };
334
+ }
335
+ }
336
+
337
+ const presented = await (options.presentSurface ?? presentGhosttySurface)({
338
+ execFileAsync,
339
+ env,
340
+ hostPid: Number(target.pid),
341
+ terminalFamily: record.terminalFamily,
342
+ surfaceId: record.terminalSurfaceId,
343
+ });
314
344
  try {
315
- await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(target.id)], {
316
- env,
317
- });
318
- return { ok: true, windowId: target.id };
345
+ await focusWindow();
319
346
  } catch {
320
- return { ok: false, error: "Niri rejected the exact focus request; focus did nothing." };
347
+ return {
348
+ ok: false,
349
+ windowId: targetId,
350
+ presented: false,
351
+ error: presented.ok
352
+ ? "Presented the hidden tab, but Niri rejected the window focus request."
353
+ : "Niri rejected the exact focus request; focus did nothing.",
354
+ };
355
+ }
356
+ if (!presented.ok) {
357
+ return {
358
+ ok: false,
359
+ windowId: targetId,
360
+ presented: false,
361
+ error: `Focused the Ghostty window, but could not present the hidden tab (${presented.error}); select it manually.`,
362
+ };
321
363
  }
364
+ if (!canVerifyTabVisibility(record, placementOptions)) {
365
+ // Nothing this session writes is readable from the compositor, so the presented tab cannot be
366
+ // confirmed. The action succeeded; the outcome is reported without claiming proof.
367
+ return { ok: true, windowId: targetId, presented: true, verified: false };
368
+ }
369
+ const sleep =
370
+ options.sleep ??
371
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
372
+ for (let attempt = 0; attempt < PRESENT_VERIFY_ATTEMPTS; attempt += 1) {
373
+ if (attempt > 0) await sleep(PRESENT_VERIFY_DELAY_MS);
374
+ try {
375
+ const current = await readWindows();
376
+ const confirmed = placeSessionWindow(current, record, placementOptions);
377
+ if (confirmed?.placement === "title" && Number(confirmed.window.id) === targetId) {
378
+ return { ok: true, windowId: targetId, presented: true, verified: true };
379
+ }
380
+ } catch {
381
+ break;
382
+ }
383
+ }
384
+ return {
385
+ ok: false,
386
+ windowId: targetId,
387
+ presented: false,
388
+ error:
389
+ "Focused the Ghostty window, but the hidden tab did not become visible; select it manually.",
390
+ };
322
391
  }
@@ -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,30 +39,46 @@ 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 lastEventAt = Number(session.lastEventAt ?? updatedAt) || updatedAt;
29
- 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);
30
48
  const agentStartedAt =
31
- session.agentStartedAt == null ? null : Number(session.agentStartedAt) || null;
49
+ session.agentStartedAt == null || !normalizedAgentStartedAt ? null : normalizedAgentStartedAt;
32
50
  const rawState = typeof session.state === "string" ? session.state : "idle";
33
51
  /** @type {SessionState} */
34
52
  const state = SESSION_STATES.has(/** @type {SessionState} */ (rawState))
35
53
  ? /** @type {SessionState} */ (rawState)
36
54
  : "idle";
37
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;
38
65
  return {
39
- sessionId: String(session.sessionId ?? ""),
40
- publisherId: String(session.publisherId ?? ""),
66
+ sessionId: boundedString(session.sessionId, 256),
67
+ publisherId: boundedString(session.publisherId, 256),
68
+ publisherSequence,
41
69
  processId: Number(session.processId ?? 0) || 0,
42
- cwd: String(session.cwd ?? ""),
43
- repoLabel: String(session.repoLabel ?? "pi session"),
44
- sessionName: String(session.sessionName ?? ""),
45
- phase: String(session.phase ?? "Idle"),
46
- detail: String(session.detail ?? "Ready"),
47
- assistantPreview: String(session.assistantPreview ?? ""),
48
- toolName: String(session.toolName ?? ""),
49
- 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),
50
82
  state,
51
83
  turnIndex: Number(session.turnIndex ?? 0) || 0,
52
84
  updatedAt,
@@ -54,8 +86,8 @@ export function normalizeSessionSnapshot(session = {}) {
54
86
  startedAt,
55
87
  agentStartedAt,
56
88
  agentActive: Boolean(session.agentActive),
57
- lastPromptPreview: String(session.lastPromptPreview ?? ""),
58
- errorMessage: String(session.errorMessage ?? ""),
89
+ lastPromptPreview: boundedString(session.lastPromptPreview, 2048),
90
+ errorMessage: boundedString(session.errorMessage, 2048),
59
91
  };
60
92
  }
61
93
 
@@ -0,0 +1,105 @@
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[]; publisherSessionIds: 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[]; publisherSessionIds: 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
+ publisherSessionIds: [
83
+ ...new Set(group.map((session) => String(session.sessionId ?? "")).filter(Boolean)),
84
+ ],
85
+ });
86
+ }
87
+ return cards;
88
+ }
89
+
90
+ /** @param {SessionLike[]} [left] @param {SessionLike[]} [right] */
91
+ export function haveSameRecordMembership(left = [], right = []) {
92
+ if (left.length !== right.length) return false;
93
+ const leftKeys = left.map(sessionMembershipKey).sort();
94
+ const rightKeys = right.map(sessionMembershipKey).sort();
95
+ return leftKeys.every((key, index) => key === rightKeys[index]);
96
+ }
97
+
98
+ /** @param {SessionLike[]} [left] @param {SessionLike[]} [right] */
99
+ export function haveSameCardMembership(left = [], right = []) {
100
+ const leftKeys = [...new Set(left.map(sessionCardId).filter(Boolean))].sort();
101
+ const rightKeys = [...new Set(right.map(sessionCardId).filter(Boolean))].sort();
102
+ return (
103
+ leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
104
+ );
105
+ }