@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.
- package/README.md +136 -157
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +50 -60
- package/native/bin/linux-x64-gnu/artifact.json +21 -0
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/Cargo.lock +1045 -0
- package/native/panel/Cargo.toml +23 -0
- package/native/panel/src/app.rs +606 -0
- package/native/panel/src/card_view.rs +602 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +297 -0
- package/native/panel/src/runtime.rs +151 -0
- package/native/panel/src/style.css +250 -0
- package/package.json +16 -4
- package/src/broker/server.mjs +43 -7
- package/src/broker/session-store.mjs +41 -13
- package/src/client/broker-client.mjs +10 -3
- package/src/client/session-telemetry.mjs +54 -14
- package/src/common/activity-order.mjs +15 -9
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +5 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +28 -13
- package/src/common/constants.mjs +10 -28
- package/src/common/contracts.d.ts +68 -4
- package/src/common/contracts.ts +105 -4
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +209 -140
- package/src/common/protocol.mjs +48 -16
- package/src/common/session-cards.mjs +105 -0
- package/src/common/status-report.mjs +53 -1
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/telemetry.mjs +8 -1
- package/src/common/terminal-identity.mjs +212 -0
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +499 -0
- package/src/native/panel-projection.mjs +161 -0
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
- package/src/common/electron.mjs +0 -57
- package/src/electron/main.mjs +0 -499
- package/src/electron/niri-native-window-runtime.mjs +0 -178
- package/src/electron/preload.cjs +0 -50
- package/src/electron/renderer-visibility-runtime.mjs +0 -107
- package/src/electron/workspace-view-runtime.mjs +0 -360
- 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}
|
|
96
|
-
export function resolveSnapshotSession(sessions,
|
|
97
|
-
const requestedId = String(
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
-
*
|
|
143
|
-
*
|
|
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 {
|
|
147
|
-
* @param {
|
|
188
|
+
* @param {string} sessionId
|
|
189
|
+
* @param {Record<string, unknown>} [session]
|
|
190
|
+
* @param {PlacementOptions} [options]
|
|
191
|
+
* @returns {WindowPlacement | null}
|
|
148
192
|
*/
|
|
149
|
-
export function
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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>}
|
|
168
|
-
* @param {
|
|
169
|
-
* @
|
|
207
|
+
* @param {Record<string, unknown>} session
|
|
208
|
+
* @param {PlacementOptions} [options]
|
|
209
|
+
* @returns {WindowPlacement | null}
|
|
170
210
|
*/
|
|
171
|
-
export function
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* @param {
|
|
200
|
-
* @param {
|
|
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
|
|
204
|
-
|
|
205
|
-
return
|
|
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 {
|
|
241
|
+
* @param {Record<string, unknown>} session
|
|
242
|
+
* @param {ResolveOptions} [options]
|
|
243
|
+
* @returns {WindowPlacement | null}
|
|
222
244
|
*/
|
|
223
|
-
export function
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* @param {
|
|
238
|
-
* @param {Array<unknown
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 {
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
308
|
-
if (!
|
|
317
|
+
const placed = placeSessionWindow(windows, record, placementOptions);
|
|
318
|
+
if (!placed) {
|
|
309
319
|
return {
|
|
310
320
|
ok: false,
|
|
311
|
-
error: "
|
|
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
|
|
316
|
-
env,
|
|
317
|
-
});
|
|
318
|
-
return { ok: true, windowId: target.id };
|
|
345
|
+
await focusWindow();
|
|
319
346
|
} catch {
|
|
320
|
-
return {
|
|
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
|
}
|
package/src/common/protocol.mjs
CHANGED
|
@@ -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 =
|
|
28
|
-
const lastEventAt =
|
|
29
|
-
const startedAt =
|
|
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 :
|
|
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:
|
|
40
|
-
publisherId:
|
|
66
|
+
sessionId: boundedString(session.sessionId, 256),
|
|
67
|
+
publisherId: boundedString(session.publisherId, 256),
|
|
68
|
+
publisherSequence,
|
|
41
69
|
processId: Number(session.processId ?? 0) || 0,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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:
|
|
58
|
-
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
|
+
}
|