@tryinget/pi-activity-strip 0.2.0 → 0.3.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 +37 -6
- package/bin/pi-activity-strip.mjs +55 -5
- package/examples/simulate-sessions.mjs +5 -0
- package/extensions/activity-strip.js +6 -0
- package/extensions/activity-strip.ts +6 -0
- package/package.json +4 -4
- package/src/broker/server.mjs +23 -2
- package/src/broker/session-store.mjs +6 -0
- package/src/client/broker-client.mjs +6 -0
- package/src/client/launcher.mjs +6 -0
- package/src/client/session-telemetry.mjs +11 -2
- package/src/common/activity-order.mjs +68 -0
- package/src/common/alignment-controller.mjs +74 -0
- package/src/common/compatibility.mjs +14 -2
- package/src/common/constants.mjs +10 -0
- package/src/common/contracts.d.ts +10 -0
- package/src/common/contracts.ts +10 -0
- package/src/common/electron.mjs +6 -0
- package/src/common/format.mjs +6 -0
- package/src/common/niri-focus.mjs +322 -0
- package/src/common/protocol.mjs +6 -0
- package/src/common/status-report.mjs +6 -0
- package/src/common/telemetry.mjs +13 -3
- package/src/electron/main.mjs +307 -127
- package/src/electron/niri-native-window-runtime.mjs +178 -0
- package/src/electron/niri-workspace-events.mjs +81 -0
- package/src/electron/preload.cjs +40 -0
- package/src/electron/renderer-visibility-runtime.mjs +107 -0
- package/src/electron/workspace-view-runtime.mjs +360 -0
- package/src/ui/strip-html.mjs +351 -242
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "resolves Pi session identity to exactly one Ghostty Niri window and focuses it fail-closed"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing card activation, CLI focus, Ghostty matching, or Niri workspace following"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
|
|
11
|
+
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
|
+
const SESSION_PRESENCE_SOURCE = "@tryinget/pi-little-helpers/session-presence";
|
|
13
|
+
const SESSION_TITLE_TOKEN_HEX_LENGTH = 32;
|
|
14
|
+
const LEGACY_SESSION_TITLE_TOKEN_HEX_LENGTH = 8;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read one bounded Niri JSON list. Polling callers treat command, timeout,
|
|
18
|
+
* parsing, and payload-shape failures as an empty fail-closed observation.
|
|
19
|
+
* @param {"windows" | "workspaces"} subject
|
|
20
|
+
* @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
|
|
21
|
+
* @param {NodeJS.ProcessEnv} env
|
|
22
|
+
* @param {number} timeout
|
|
23
|
+
*/
|
|
24
|
+
async function readNiriList(subject, execFileAsync, env, timeout) {
|
|
25
|
+
if (!env.NIRI_SOCKET) return [];
|
|
26
|
+
try {
|
|
27
|
+
const { stdout } = await execFileAsync("niri", ["msg", "-j", subject], { env, timeout });
|
|
28
|
+
const payload = JSON.parse(String(stdout ?? "[]"));
|
|
29
|
+
return Array.isArray(payload) ? payload : [];
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {Parameters<typeof readNiriList>[1]} execFileAsync @param {NodeJS.ProcessEnv} env @param {number} timeout */
|
|
36
|
+
export function readNiriWindows(execFileAsync, env, timeout) {
|
|
37
|
+
return readNiriList("windows", execFileAsync, env, timeout);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {Parameters<typeof readNiriList>[1]} execFileAsync @param {NodeJS.ProcessEnv} env @param {number} timeout */
|
|
41
|
+
export function readNiriWorkspaces(execFileAsync, env, timeout) {
|
|
42
|
+
return readNiriList("workspaces", execFileAsync, env, timeout);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the exact Pi identity carried by current telemetry. Sessions that
|
|
47
|
+
* started before the activity-strip upgrade retain a legacy broker id, so use
|
|
48
|
+
* their process-bound session-presence sidecar as the only migration bridge.
|
|
49
|
+
* The sidecar must agree on source, pid, and cwd; otherwise fail closed.
|
|
50
|
+
* @param {string | Record<string, unknown>} session
|
|
51
|
+
* @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePiSessionIdentity(session, options = {}) {
|
|
54
|
+
const record =
|
|
55
|
+
session && typeof session === "object" ? session : { sessionId: String(session ?? "") };
|
|
56
|
+
const directId = String(record.sessionId ?? "").trim();
|
|
57
|
+
if (PI_SESSION_ID.test(directId)) return directId;
|
|
58
|
+
|
|
59
|
+
const processId = Number(record.processId ?? 0);
|
|
60
|
+
const runtimeDir = String(
|
|
61
|
+
options.env?.XDG_RUNTIME_DIR ?? process.env.XDG_RUNTIME_DIR ?? "",
|
|
62
|
+
).trim();
|
|
63
|
+
if (!Number.isInteger(processId) || processId <= 0 || !runtimeDir) return null;
|
|
64
|
+
const expectedCwd = String(record.cwd ?? "").trim();
|
|
65
|
+
if (!expectedCwd) return null;
|
|
66
|
+
const existsSync = options.existsSync ?? fs.existsSync;
|
|
67
|
+
if (!existsSync(path.join("/proc", String(processId)))) return null;
|
|
68
|
+
|
|
69
|
+
const readFileSync = options.readFileSync ?? fs.readFileSync;
|
|
70
|
+
let presence;
|
|
71
|
+
try {
|
|
72
|
+
const filePath = path.join(runtimeDir, "pi-session-presence", `${processId}.json`);
|
|
73
|
+
presence = JSON.parse(readFileSync(filePath, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!presence || typeof presence !== "object") return null;
|
|
79
|
+
if (presence.source !== SESSION_PRESENCE_SOURCE || Number(presence.pid) !== processId)
|
|
80
|
+
return null;
|
|
81
|
+
if (String(presence.cwd ?? "").trim() !== expectedCwd) return null;
|
|
82
|
+
const presenceId = String(presence.sessionId ?? "").trim();
|
|
83
|
+
return PI_SESSION_ID.test(presenceId) ? presenceId : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {string} value @param {number} [hexLength] */
|
|
87
|
+
export function shortSessionId(value, hexLength = SESSION_TITLE_TOKEN_HEX_LENGTH) {
|
|
88
|
+
return String(value ?? "")
|
|
89
|
+
.trim()
|
|
90
|
+
.replaceAll("-", "")
|
|
91
|
+
.slice(0, hexLength)
|
|
92
|
+
.toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
|
|
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;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Title matching prefers the full hyphenless 32-hex identity suffix emitted by current Pi session
|
|
104
|
+
* presence. The legacy 8-hex suffix remains a migration fallback only when it is unambiguous. The app id
|
|
105
|
+
* must also identify a known Ghostty build, and ambiguity always returns no match.
|
|
106
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
107
|
+
* @param {string} sessionId
|
|
108
|
+
*/
|
|
109
|
+
export function resolveExactGhosttyWindow(windows, sessionId) {
|
|
110
|
+
const fullId = String(sessionId ?? "").trim();
|
|
111
|
+
if (!PI_SESSION_ID.test(fullId)) return null;
|
|
112
|
+
|
|
113
|
+
/** @param {Record<string, unknown>} window */
|
|
114
|
+
const isKnownGhosttyWindow = (window) =>
|
|
115
|
+
Number.isInteger(window?.id) && GHOSTTY_APP_IDS.has(String(window?.app_id ?? ""));
|
|
116
|
+
|
|
117
|
+
/** @param {string} token */
|
|
118
|
+
const matchesForToken = (token) => {
|
|
119
|
+
const suffix = ` · ${token}`;
|
|
120
|
+
return windows.filter(
|
|
121
|
+
(window) => isKnownGhosttyWindow(window) && String(window?.title ?? "").endsWith(suffix),
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const currentToken = shortSessionId(fullId);
|
|
126
|
+
const currentMatches = matchesForToken(currentToken);
|
|
127
|
+
if (currentMatches.length > 0) return currentMatches.length === 1 ? currentMatches[0] : null;
|
|
128
|
+
|
|
129
|
+
const legacyToken = shortSessionId(fullId, LEGACY_SESSION_TITLE_TOKEN_HEX_LENGTH);
|
|
130
|
+
const migratedPrefixMatches = windows.filter((window) => {
|
|
131
|
+
if (!isKnownGhosttyWindow(window)) return false;
|
|
132
|
+
const match = String(window?.title ?? "").match(/ · ([0-9a-f]{32})$/i);
|
|
133
|
+
return match?.[1].toLowerCase().startsWith(legacyToken) === true;
|
|
134
|
+
});
|
|
135
|
+
if (migratedPrefixMatches.length > 0) return null;
|
|
136
|
+
|
|
137
|
+
const legacyMatches = matchesForToken(legacyToken);
|
|
138
|
+
return legacyMatches.length === 1 ? legacyMatches[0] : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
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.
|
|
145
|
+
* @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]
|
|
148
|
+
*/
|
|
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;
|
|
157
|
+
});
|
|
158
|
+
return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
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.
|
|
166
|
+
* @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]
|
|
170
|
+
*/
|
|
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);
|
|
185
|
+
}
|
|
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
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
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]
|
|
202
|
+
*/
|
|
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;
|
|
217
|
+
}
|
|
218
|
+
|
|
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
|
+
/**
|
|
279
|
+
* @param {string | Record<string, unknown>} session
|
|
280
|
+
* @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
|
|
281
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
282
|
+
* @param {{readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
|
|
283
|
+
*/
|
|
284
|
+
export async function focusNiriSession(session, execFileAsync, env = process.env, options = {}) {
|
|
285
|
+
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) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
error: "Exact Pi identity is unavailable; reload that Pi tab and try again.",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
let windows;
|
|
298
|
+
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." };
|
|
306
|
+
}
|
|
307
|
+
const target = resolveExactGhosttyWindow(windows, sessionId);
|
|
308
|
+
if (!target) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: "Pi identity did not resolve to exactly one Ghostty window; focus did nothing.",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(target.id)], {
|
|
316
|
+
env,
|
|
317
|
+
});
|
|
318
|
+
return { ok: true, windowId: target.id };
|
|
319
|
+
} catch {
|
|
320
|
+
return { ok: false, error: "Niri rejected the exact focus request; focus did nothing." };
|
|
321
|
+
}
|
|
322
|
+
}
|
package/src/common/protocol.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "creates broker messages and normalizes and prioritizes session snapshots for transport and display"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing message envelopes, session defaults, accepted states, or sort priority"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
1
7
|
import { randomUUID } from "node:crypto";
|
|
2
8
|
|
|
3
9
|
/** @typedef {import("./contracts.ts").SessionState} SessionState */
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "converts broker runtime responses into detailed status text and concise UI notifications"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing CLI status output or extension-facing readiness summaries"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
1
7
|
/** @typedef {import("./contracts.ts").BrokerResponse} BrokerResponse */
|
|
2
8
|
|
|
3
9
|
/** @param {BrokerResponse | null | undefined} result */
|
package/src/common/telemetry.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "builds initial session telemetry and describes tool calls and results as strip-ready status updates"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing session identity, tool activity descriptions, or result summarization"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
1
7
|
import { randomUUID } from "node:crypto";
|
|
2
8
|
import os from "node:os";
|
|
3
9
|
/** @typedef {import("./contracts.ts").SessionSnapshot} SessionSnapshot */
|
|
@@ -61,11 +67,15 @@ export function createSessionId() {
|
|
|
61
67
|
return `${os.hostname()}-${process.pid}-${Date.now()}-${randomUUID().slice(0, 6)}`;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
/** @param {{ cwd?: string; sessionName?: string }} [options] @returns {SessionSnapshot} */
|
|
65
|
-
export function createInitialSnapshot({
|
|
70
|
+
/** @param {{ cwd?: string; sessionName?: string; sessionId?: string }} [options] @returns {SessionSnapshot} */
|
|
71
|
+
export function createInitialSnapshot({
|
|
72
|
+
cwd = process.cwd(),
|
|
73
|
+
sessionName = "",
|
|
74
|
+
sessionId = "",
|
|
75
|
+
} = {}) {
|
|
66
76
|
const now = Date.now();
|
|
67
77
|
return {
|
|
68
|
-
sessionId: createSessionId(),
|
|
78
|
+
sessionId: sessionId.trim() || createSessionId(),
|
|
69
79
|
processId: process.pid,
|
|
70
80
|
cwd,
|
|
71
81
|
repoLabel: formatRepoLabel(cwd, sessionName),
|