@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
|
@@ -14,6 +14,7 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
14
14
|
|
|
15
15
|
const lines = [
|
|
16
16
|
runtime.state === "ready" ? "running (ready)" : `running (${runtime.state})`,
|
|
17
|
+
`backend: ${runtime.backend || "unknown"}`,
|
|
17
18
|
`display: ${runtime.displayServer || "unknown"}`,
|
|
18
19
|
`window manager: ${runtime.windowManager || "unknown"}`,
|
|
19
20
|
`alignment: ${runtime.alignmentMode || "unknown"}`,
|
|
@@ -23,7 +24,58 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
23
24
|
lines.push(`displays: ${runtime.displayCount}`);
|
|
24
25
|
}
|
|
25
26
|
if (typeof runtime.windowVisible === "boolean") {
|
|
26
|
-
lines.push(`
|
|
27
|
+
lines.push(`surface visible: ${runtime.windowVisible ? "yes" : "no"}`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof runtime.rendererCardCount === "number") {
|
|
30
|
+
const hidden =
|
|
31
|
+
typeof runtime.rendererHiddenTabCardCount === "number"
|
|
32
|
+
? ` (${runtime.rendererHiddenTabCardCount} hidden tabs)`
|
|
33
|
+
: "";
|
|
34
|
+
lines.push(`cards on focused workspace: ${runtime.rendererCardCount}${hidden}`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof runtime.surfaceBindingCount === "number") {
|
|
37
|
+
lines.push(`remembered tab windows: ${runtime.surfaceBindingCount}`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof runtime.agentTabCount === "number") {
|
|
40
|
+
lines.push(`agent tabs discovered: ${runtime.agentTabCount}`);
|
|
41
|
+
}
|
|
42
|
+
if (runtime.themeName) {
|
|
43
|
+
lines.push(`theme: ${runtime.themeName} (${runtime.themeScheme ?? "unknown"})`);
|
|
44
|
+
}
|
|
45
|
+
if (runtime.agentScanError) {
|
|
46
|
+
lines.push(`agent discovery error: ${runtime.agentScanError}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof runtime.heightRepairState === "string") {
|
|
49
|
+
const repaired = Number(runtime.heightRepairCount ?? 0);
|
|
50
|
+
lines.push(
|
|
51
|
+
`window height repair: ${runtime.heightRepairState}${repaired > 0 ? ` (${repaired} restored to automatic)` : ""}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (typeof runtime.tabInventoryState === "string") {
|
|
55
|
+
const detail =
|
|
56
|
+
runtime.tabInventoryState === "ready"
|
|
57
|
+
? ` (${runtime.tabInventoryFrameCount ?? 0} Ghostty windows, ${runtime.tabInventoryTabCount ?? 0} tabs)`
|
|
58
|
+
: runtime.tabInventoryDetail
|
|
59
|
+
? ` (${runtime.tabInventoryDetail})`
|
|
60
|
+
: "";
|
|
61
|
+
lines.push(`tab inventory: ${runtime.tabInventoryState}${detail}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof runtime.akTaskState === "string") {
|
|
64
|
+
if (runtime.akTaskState === "ok") {
|
|
65
|
+
const orphaned = Number(runtime.akTaskOrphanCount ?? 0);
|
|
66
|
+
lines.push(
|
|
67
|
+
`AK task refs: ok (${runtime.akTaskClaimCount ?? 0} live claims, ${runtime.akTaskDeferredCount ?? 0} deferred${orphaned > 0 ? `, ${orphaned} orphaned claims` : ""})`,
|
|
68
|
+
);
|
|
69
|
+
} else if (runtime.akTaskState !== "disabled") {
|
|
70
|
+
lines.push(
|
|
71
|
+
`AK task refs: ${runtime.akTaskState}${runtime.akTaskError ? ` (${runtime.akTaskError})` : ""}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (typeof runtime.unplacedSurfaceCount === "number" && runtime.unplacedSurfaceCount > 0) {
|
|
76
|
+
lines.push(
|
|
77
|
+
`unplaced hidden tabs: ${runtime.unplacedSurfaceCount} (placed once the tab inventory or a visible title reveals their window)`,
|
|
78
|
+
);
|
|
27
79
|
}
|
|
28
80
|
if (runtime.error) {
|
|
29
81
|
lines.push(`error: ${runtime.error}`);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "remembers which Niri window last displayed each Ghostty terminal surface so hidden tabs stay placed"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing hidden-tab placement memory, its persistence, or its invalidation rules"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { appIdForGhosttyFamily, normalizeGhosttySurfaceId } from "./terminal-identity.mjs";
|
|
10
|
+
|
|
11
|
+
export const SURFACE_BINDINGS_SCHEMA = "pi-activity-strip-surface-bindings.v1";
|
|
12
|
+
const TITLE_BINDING = / · gs:(main|legacy):(\d+) · ([0-9a-f]{32})$/i;
|
|
13
|
+
const SESSION_BINDING_PREFIX = "pi-session:";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ghostty surface ids are per-process handles that can drift from the value a long-lived Pi
|
|
17
|
+
* process captured at startup, while the 32-hex session token in the same title never does. Both
|
|
18
|
+
* keys are therefore remembered, and lookups prefer the more specific surface key.
|
|
19
|
+
* @param {unknown} sessionToken
|
|
20
|
+
*/
|
|
21
|
+
export function sessionBindingKey(sessionToken) {
|
|
22
|
+
const token = String(sessionToken ?? "")
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase();
|
|
25
|
+
return /^[0-9a-f]{32}$/.test(token) ? `${SESSION_BINDING_PREFIX}${token}` : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @typedef {{windowId: number; windowPid: number; appId: string; observedAt: number}} SurfaceBinding */
|
|
29
|
+
/** @typedef {{instanceKey: string; filePath?: string; fs?: Pick<typeof fs, "readFileSync" | "writeFileSync" | "renameSync" | "mkdirSync" | "unlinkSync">; now?: () => number}} SurfaceBindingStoreOptions */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A Ghostty window title only names its active tab. Parse that name into the bound terminal key so
|
|
33
|
+
* every observation of a visible title teaches which window currently owns the surface.
|
|
34
|
+
* @param {unknown} title
|
|
35
|
+
*/
|
|
36
|
+
export function parseTerminalTitleBinding(title) {
|
|
37
|
+
const match = String(title ?? "").match(TITLE_BINDING);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
const surfaceId = normalizeGhosttySurfaceId(match[2]);
|
|
40
|
+
if (!surfaceId) return null;
|
|
41
|
+
const family = String(match[1]).toLowerCase();
|
|
42
|
+
const sessionToken = String(match[3]).toLowerCase();
|
|
43
|
+
return {
|
|
44
|
+
family,
|
|
45
|
+
surfaceId,
|
|
46
|
+
sessionToken,
|
|
47
|
+
terminalKey: `ghostty:${family}:${surfaceId}`,
|
|
48
|
+
sessionKey: sessionBindingKey(sessionToken),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every binding key a title or tab label teaches: the exact surface, and the logical session.
|
|
54
|
+
* @param {ReturnType<typeof parseTerminalTitleBinding>} parsed
|
|
55
|
+
*/
|
|
56
|
+
export function bindingKeysFor(parsed) {
|
|
57
|
+
return parsed ? [parsed.terminalKey, parsed.sessionKey].filter(Boolean) : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bindings are memory, not proof: they are only consulted for windows that still exist, still
|
|
62
|
+
* belong to the same Ghostty process, and are still admitted by the terminal's own host process.
|
|
63
|
+
* Persisted memory is bound to one Niri instance because window ids restart with the compositor.
|
|
64
|
+
* @param {SurfaceBindingStoreOptions} options
|
|
65
|
+
*/
|
|
66
|
+
export function createSurfaceBindingStore({
|
|
67
|
+
instanceKey,
|
|
68
|
+
filePath = "",
|
|
69
|
+
fs: fsImpl = fs,
|
|
70
|
+
now = Date.now,
|
|
71
|
+
}) {
|
|
72
|
+
/** @type {Map<string, SurfaceBinding>} */
|
|
73
|
+
const bindings = new Map();
|
|
74
|
+
let dirty = false;
|
|
75
|
+
|
|
76
|
+
function load() {
|
|
77
|
+
if (!filePath) return;
|
|
78
|
+
/** @type {any} */
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (parsed?.schema !== SURFACE_BINDINGS_SCHEMA || parsed.instanceKey !== instanceKey) return;
|
|
86
|
+
const entries = /** @type {Array<[string, any]>} */ (Object.entries(parsed.bindings ?? {}));
|
|
87
|
+
for (const [terminalKey, value] of entries) {
|
|
88
|
+
if (
|
|
89
|
+
!Number.isInteger(value?.windowId) ||
|
|
90
|
+
!Number.isInteger(value?.windowPid) ||
|
|
91
|
+
typeof value?.appId !== "string"
|
|
92
|
+
) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
bindings.set(terminalKey, {
|
|
96
|
+
windowId: value.windowId,
|
|
97
|
+
windowPid: value.windowPid,
|
|
98
|
+
appId: value.appId,
|
|
99
|
+
observedAt: Number(value.observedAt) || 0,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function persist() {
|
|
105
|
+
if (!filePath || !dirty) return false;
|
|
106
|
+
const temporaryPath = `${filePath}.tmp.${process.pid}`;
|
|
107
|
+
try {
|
|
108
|
+
fsImpl.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
109
|
+
fsImpl.writeFileSync(
|
|
110
|
+
temporaryPath,
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
schema: SURFACE_BINDINGS_SCHEMA,
|
|
113
|
+
instanceKey,
|
|
114
|
+
savedAt: now(),
|
|
115
|
+
bindings: Object.fromEntries(bindings),
|
|
116
|
+
}),
|
|
117
|
+
{ mode: 0o600 },
|
|
118
|
+
);
|
|
119
|
+
fsImpl.renameSync(temporaryPath, filePath);
|
|
120
|
+
dirty = false;
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
try {
|
|
124
|
+
fsImpl.unlinkSync(temporaryPath);
|
|
125
|
+
} catch {
|
|
126
|
+
// The temporary file never existed or is already gone.
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Learn from every Ghostty window whose title carries a surface segment. Accepts a complete
|
|
134
|
+
* window list or a single window event. Returns the terminal keys whose binding changed.
|
|
135
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
136
|
+
*/
|
|
137
|
+
function observeWindows(windows) {
|
|
138
|
+
const changed = [];
|
|
139
|
+
for (const window of windows ?? []) {
|
|
140
|
+
if (!Number.isInteger(window?.id) || !Number.isInteger(window?.pid)) continue;
|
|
141
|
+
const parsed = parseTerminalTitleBinding(window.title);
|
|
142
|
+
if (!parsed) continue;
|
|
143
|
+
const appId = String(window.app_id ?? "");
|
|
144
|
+
if (appId !== appIdForGhosttyFamily(parsed.family)) continue;
|
|
145
|
+
for (const key of bindingKeysFor(parsed)) {
|
|
146
|
+
const previous = bindings.get(key);
|
|
147
|
+
if (previous?.windowId === window.id && previous?.windowPid === window.pid) continue;
|
|
148
|
+
bindings.set(key, {
|
|
149
|
+
windowId: Number(window.id),
|
|
150
|
+
windowPid: Number(window.pid),
|
|
151
|
+
appId,
|
|
152
|
+
observedAt: now(),
|
|
153
|
+
});
|
|
154
|
+
changed.push(key);
|
|
155
|
+
dirty = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return changed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Adopt bindings established by an exact external inventory (for example AT-SPI tab labels
|
|
163
|
+
* already mapped onto windows). Returns the terminal keys whose binding changed.
|
|
164
|
+
* @param {Array<{bindingKey: string; windowId: number; windowPid: number; appId: string}>} observed
|
|
165
|
+
*/
|
|
166
|
+
function observeBindings(observed) {
|
|
167
|
+
const changed = [];
|
|
168
|
+
for (const binding of observed ?? []) {
|
|
169
|
+
if (
|
|
170
|
+
typeof binding?.bindingKey !== "string" ||
|
|
171
|
+
!binding.bindingKey ||
|
|
172
|
+
!Number.isInteger(binding.windowId) ||
|
|
173
|
+
!Number.isInteger(binding.windowPid) ||
|
|
174
|
+
typeof binding.appId !== "string"
|
|
175
|
+
) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const previous = bindings.get(binding.bindingKey);
|
|
179
|
+
if (previous?.windowId === binding.windowId && previous?.windowPid === binding.windowPid) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
bindings.set(binding.bindingKey, {
|
|
183
|
+
windowId: binding.windowId,
|
|
184
|
+
windowPid: binding.windowPid,
|
|
185
|
+
appId: binding.appId,
|
|
186
|
+
observedAt: now(),
|
|
187
|
+
});
|
|
188
|
+
changed.push(binding.bindingKey);
|
|
189
|
+
dirty = true;
|
|
190
|
+
}
|
|
191
|
+
return changed;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Forget bindings whose window vanished or changed process identity. Only a complete window list
|
|
196
|
+
* may prune; an empty observation is indistinguishable from a failed read and is ignored.
|
|
197
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
198
|
+
*/
|
|
199
|
+
function prune(windows) {
|
|
200
|
+
if (!Array.isArray(windows) || windows.length === 0) return [];
|
|
201
|
+
const byId = new Map(
|
|
202
|
+
windows.filter((window) => Number.isInteger(window?.id)).map((window) => [window.id, window]),
|
|
203
|
+
);
|
|
204
|
+
const removed = [];
|
|
205
|
+
for (const [terminalKey, binding] of bindings) {
|
|
206
|
+
const window = byId.get(binding.windowId);
|
|
207
|
+
if (
|
|
208
|
+
window &&
|
|
209
|
+
window.pid === binding.windowPid &&
|
|
210
|
+
String(window.app_id ?? "") === binding.appId
|
|
211
|
+
) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
bindings.delete(terminalKey);
|
|
215
|
+
removed.push(terminalKey);
|
|
216
|
+
dirty = true;
|
|
217
|
+
}
|
|
218
|
+
return removed;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
load();
|
|
222
|
+
return {
|
|
223
|
+
observeWindows,
|
|
224
|
+
observeBindings,
|
|
225
|
+
prune,
|
|
226
|
+
persist,
|
|
227
|
+
/** @param {string} terminalKey */
|
|
228
|
+
lookup: (terminalKey) => bindings.get(terminalKey) ?? null,
|
|
229
|
+
entries: () => [...bindings.entries()],
|
|
230
|
+
get size() {
|
|
231
|
+
return bindings.size;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
package/src/common/telemetry.mjs
CHANGED
|
@@ -67,18 +67,25 @@ export function createSessionId() {
|
|
|
67
67
|
return `${os.hostname()}-${process.pid}-${Date.now()}-${randomUUID().slice(0, 6)}`;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** @param {{ cwd?: string; sessionName?: string; sessionId?: string; publisherId?: string }} [options] @returns {SessionSnapshot} */
|
|
70
|
+
/** @param {{ cwd?: string; sessionName?: string; sessionId?: string; publisherId?: string; terminalIdentity?: {terminalKind?: string; terminalKey?: string; terminalFamily?: string; terminalSurfaceId?: string} }} [options] @returns {SessionSnapshot} */
|
|
71
71
|
export function createInitialSnapshot({
|
|
72
72
|
cwd = process.cwd(),
|
|
73
73
|
sessionName = "",
|
|
74
74
|
sessionId = "",
|
|
75
75
|
publisherId = "",
|
|
76
|
+
terminalIdentity = {},
|
|
76
77
|
} = {}) {
|
|
77
78
|
const now = Date.now();
|
|
78
79
|
return {
|
|
79
80
|
sessionId: sessionId.trim() || createSessionId(),
|
|
80
81
|
publisherId: publisherId.trim() || randomUUID(),
|
|
82
|
+
publisherSequence: 0,
|
|
81
83
|
processId: process.pid,
|
|
84
|
+
terminalKind:
|
|
85
|
+
terminalIdentity.terminalKind === "ghostty-surface" ? "ghostty-surface" : "unbound",
|
|
86
|
+
terminalKey: String(terminalIdentity.terminalKey ?? ""),
|
|
87
|
+
terminalFamily: String(terminalIdentity.terminalFamily ?? ""),
|
|
88
|
+
terminalSurfaceId: String(terminalIdentity.terminalSurfaceId ?? ""),
|
|
82
89
|
cwd,
|
|
83
90
|
repoLabel: formatRepoLabel(cwd, sessionName),
|
|
84
91
|
sessionName: compactWhitespace(sessionName),
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "derives bounded terminal-surface identities for activity-strip publisher and focus contracts"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing terminal card identity, Ghostty surface admission, or title matching"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const UINT64_MAX = 18_446_744_073_709_551_615n;
|
|
11
|
+
const GHOSTTY_MAIN_APP_ID = "com.mitchellh.ghostty";
|
|
12
|
+
const GHOSTTY_LEGACY_APP_ID = "com.tryinget.ghosttysidequest";
|
|
13
|
+
const HOST_PID_CACHE_LIMIT = 1024;
|
|
14
|
+
|
|
15
|
+
/** @typedef {{terminalKind: "ghostty-surface" | "unbound"; terminalKey: string; terminalFamily: string; terminalSurfaceId: string}} TerminalIdentity */
|
|
16
|
+
/** @typedef {{env?: NodeJS.ProcessEnv; hasUI?: boolean; stdinIsTTY?: boolean; ttyPath?: string; processId?: number; ancestorExecutable?: string}} TerminalIdentityOptions */
|
|
17
|
+
/** @typedef {{readFile: (filePath: string) => string; readLink: (filePath: string) => string}} ProcfsReader */
|
|
18
|
+
|
|
19
|
+
/** @param {string} filePath */
|
|
20
|
+
function safeReadFile(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(filePath, "utf8");
|
|
23
|
+
} catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {string} filePath */
|
|
29
|
+
function safeReadLink(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readlinkSync(filePath);
|
|
32
|
+
} catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @type {ProcfsReader} */
|
|
38
|
+
const defaultProcfs = { readFile: safeReadFile, readLink: safeReadLink };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fields after the parenthesized command name in /proc/<pid>/stat: index 1 is the parent pid and
|
|
42
|
+
* index 19 is the process start time in clock ticks since boot.
|
|
43
|
+
* @param {number} pid
|
|
44
|
+
* @param {ProcfsReader} procfs
|
|
45
|
+
*/
|
|
46
|
+
function readStatFields(pid, procfs) {
|
|
47
|
+
const value = procfs.readFile(path.join("/proc", String(pid), "stat"));
|
|
48
|
+
const lastParen = value.lastIndexOf(")");
|
|
49
|
+
if (lastParen < 0) return [];
|
|
50
|
+
return value
|
|
51
|
+
.slice(lastParen + 2)
|
|
52
|
+
.trim()
|
|
53
|
+
.split(/\s+/);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {number} pid @param {ProcfsReader} [procfs] */
|
|
57
|
+
function readParentPid(pid, procfs = defaultProcfs) {
|
|
58
|
+
const parentPid = Number.parseInt(readStatFields(pid, procfs)[1] ?? "", 10);
|
|
59
|
+
return Number.isInteger(parentPid) && parentPid > 0 ? parentPid : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {number} pid @param {ProcfsReader} [procfs] */
|
|
63
|
+
export function readProcessStartTime(pid, procfs = defaultProcfs) {
|
|
64
|
+
const startTime = readStatFields(pid, procfs)[19] ?? "";
|
|
65
|
+
return /^\d+$/.test(startTime) ? startTime : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {unknown} value */
|
|
69
|
+
export function normalizeGhosttySurfaceId(value) {
|
|
70
|
+
try {
|
|
71
|
+
const normalized = BigInt(String(value ?? "").trim());
|
|
72
|
+
return normalized >= 0n && normalized <= UINT64_MAX ? normalized.toString(10) : "";
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {unknown} executable */
|
|
79
|
+
export function classifyGhosttyFamily(executable) {
|
|
80
|
+
const normalized = String(executable ?? "").toLowerCase();
|
|
81
|
+
if (!normalized) return "";
|
|
82
|
+
if (normalized.includes("ghostty-sidequest")) return "legacy";
|
|
83
|
+
if (path.basename(normalized) === "ghostty" || normalized.includes("ghostty-origin"))
|
|
84
|
+
return "main";
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {unknown} family */
|
|
89
|
+
export function appIdForGhosttyFamily(family) {
|
|
90
|
+
if (family === "main") return GHOSTTY_MAIN_APP_ID;
|
|
91
|
+
if (family === "legacy") return GHOSTTY_LEGACY_APP_ID;
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Walk the parent chain to the nearest live Ghostty process. That process owns every Wayland window
|
|
97
|
+
* a tab of this terminal can live in, which makes it the placement authority for hidden tabs.
|
|
98
|
+
* @param {number} [processId]
|
|
99
|
+
* @param {ProcfsReader} [procfs]
|
|
100
|
+
* @returns {{pid: number; executable: string} | null}
|
|
101
|
+
*/
|
|
102
|
+
export function findGhosttyAncestor(processId = process.pid, procfs = defaultProcfs) {
|
|
103
|
+
let pid = Number(processId) || 0;
|
|
104
|
+
for (let depth = 0; depth < 12 && pid > 0; depth += 1) {
|
|
105
|
+
pid = readParentPid(pid, procfs);
|
|
106
|
+
if (pid <= 0) break;
|
|
107
|
+
const command = procfs
|
|
108
|
+
.readFile(path.join("/proc", String(pid), "comm"))
|
|
109
|
+
.trim()
|
|
110
|
+
.toLowerCase();
|
|
111
|
+
if (command === "ghostty") {
|
|
112
|
+
return { pid, executable: procfs.readLink(path.join("/proc", String(pid), "exe")) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {number} [processId] @param {ProcfsReader} [procfs] */
|
|
119
|
+
export function findGhosttyAncestorExecutable(processId = process.pid, procfs = defaultProcfs) {
|
|
120
|
+
return findGhosttyAncestor(processId, procfs)?.executable ?? "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the live Ghostty process hosting a bound terminal surface. The publisher process must
|
|
125
|
+
* still exist and its Ghostty ancestor must belong to the admitted executable family; anything else
|
|
126
|
+
* fails closed to 0. Results are cached per process start time so pid reuse cannot leak a stale host.
|
|
127
|
+
* @param {Record<string, unknown>} session
|
|
128
|
+
* @param {{procfs?: ProcfsReader; cache?: Map<string, number>}} [options]
|
|
129
|
+
*/
|
|
130
|
+
export function resolveTerminalHostPid(session, options = {}) {
|
|
131
|
+
if (!canonicalGhosttyTerminalKey(session)) return 0;
|
|
132
|
+
const processId = Number(session.processId ?? 0);
|
|
133
|
+
if (!Number.isInteger(processId) || processId <= 0) return 0;
|
|
134
|
+
const procfs = options.procfs ?? defaultProcfs;
|
|
135
|
+
const startTime = readProcessStartTime(processId, procfs);
|
|
136
|
+
if (!startTime) return 0;
|
|
137
|
+
const cacheKey = `${processId}:${startTime}`;
|
|
138
|
+
const cached = options.cache?.get(cacheKey);
|
|
139
|
+
if (cached !== undefined) return cached;
|
|
140
|
+
const ancestor = findGhosttyAncestor(processId, procfs);
|
|
141
|
+
const hostPid =
|
|
142
|
+
ancestor && classifyGhosttyFamily(ancestor.executable) === session.terminalFamily
|
|
143
|
+
? ancestor.pid
|
|
144
|
+
: 0;
|
|
145
|
+
if (options.cache) {
|
|
146
|
+
if (options.cache.size >= HOST_PID_CACHE_LIMIT) options.cache.clear();
|
|
147
|
+
options.cache.set(cacheKey, hostPid);
|
|
148
|
+
}
|
|
149
|
+
return hostPid;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @returns {TerminalIdentity} */
|
|
153
|
+
export function unboundTerminalIdentity() {
|
|
154
|
+
return {
|
|
155
|
+
terminalKind: "unbound",
|
|
156
|
+
terminalKey: "",
|
|
157
|
+
terminalFamily: "",
|
|
158
|
+
terminalSurfaceId: "",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Admit a terminal binding only for an interactive TUI attached to a real TTY and a recognized
|
|
164
|
+
* Ghostty process family. Headless descendants commonly inherit Ghostty environment variables, so
|
|
165
|
+
* GHOSTTY_SURFACE_ID alone is not evidence of an operator-visible terminal.
|
|
166
|
+
* @param {TerminalIdentityOptions} [options]
|
|
167
|
+
* @returns {TerminalIdentity}
|
|
168
|
+
*/
|
|
169
|
+
export function resolveTerminalIdentity({
|
|
170
|
+
env = process.env,
|
|
171
|
+
hasUI = false,
|
|
172
|
+
stdinIsTTY = Boolean(process.stdin.isTTY),
|
|
173
|
+
ttyPath = safeReadLink("/proc/self/fd/0"),
|
|
174
|
+
processId = process.pid,
|
|
175
|
+
ancestorExecutable,
|
|
176
|
+
} = {}) {
|
|
177
|
+
if (
|
|
178
|
+
!hasUI ||
|
|
179
|
+
!stdinIsTTY ||
|
|
180
|
+
!ttyPath.startsWith("/dev/") ||
|
|
181
|
+
String(env.TERM_PROGRAM ?? "").toLowerCase() !== "ghostty"
|
|
182
|
+
) {
|
|
183
|
+
return unboundTerminalIdentity();
|
|
184
|
+
}
|
|
185
|
+
const surfaceId = normalizeGhosttySurfaceId(env.GHOSTTY_SURFACE_ID);
|
|
186
|
+
const executable = ancestorExecutable ?? findGhosttyAncestorExecutable(processId);
|
|
187
|
+
const family = classifyGhosttyFamily(executable);
|
|
188
|
+
if (!surfaceId || !family) return unboundTerminalIdentity();
|
|
189
|
+
return {
|
|
190
|
+
terminalKind: "ghostty-surface",
|
|
191
|
+
terminalKey: `ghostty:${family}:${surfaceId}`,
|
|
192
|
+
terminalFamily: family,
|
|
193
|
+
terminalSurfaceId: surfaceId,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {Partial<TerminalIdentity> | Record<string, unknown>} identity */
|
|
198
|
+
export function canonicalGhosttyTerminalKey(identity) {
|
|
199
|
+
if (identity?.terminalKind !== "ghostty-surface") return "";
|
|
200
|
+
const family = String(identity.terminalFamily ?? "");
|
|
201
|
+
if (family !== "main" && family !== "legacy") return "";
|
|
202
|
+
const surfaceId = normalizeGhosttySurfaceId(identity.terminalSurfaceId);
|
|
203
|
+
if (!surfaceId) return "";
|
|
204
|
+
const expectedKey = `ghostty:${family}:${surfaceId}`;
|
|
205
|
+
return String(identity.terminalKey ?? "") === expectedKey ? expectedKey : "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param {Partial<TerminalIdentity> | Record<string, unknown>} identity */
|
|
209
|
+
export function terminalTitleSegment(identity) {
|
|
210
|
+
if (!canonicalGhosttyTerminalKey(identity)) return "";
|
|
211
|
+
return `gs:${identity.terminalFamily}:${normalizeGhosttySurfaceId(identity.terminalSurfaceId)}`;
|
|
212
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "places bound Pi terminal surfaces onto Niri windows, including tabs hidden behind another tab"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing how hidden Ghostty tabs are attributed to windows, workspaces, or activation targets"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { sessionBindingKey } from "./surface-bindings.mjs";
|
|
8
|
+
import { appIdForGhosttyFamily, canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {"title" | "host" | "binding"} SurfacePlacement */
|
|
11
|
+
/** @typedef {{window: Record<string, unknown>; placement: SurfacePlacement}} WindowPlacement */
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* resolveHostPid?: (session: Record<string, unknown>) => number;
|
|
15
|
+
* lookupBinding?: (bindingKey: string) => {windowId: number; windowPid: number} | null;
|
|
16
|
+
* }} PlacementOptions
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Place a bound surface whose title is not visible in any window. A surface can only live inside
|
|
21
|
+
* a window of its own Ghostty process, so a single host window is an exact placement. Several host
|
|
22
|
+
* windows require the window that last displayed this surface; without that memory the surface
|
|
23
|
+
* stays unplaced rather than guessed.
|
|
24
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
25
|
+
* @param {Record<string, unknown>} session
|
|
26
|
+
* @param {PlacementOptions & {sessionToken?: string}} [options]
|
|
27
|
+
* @returns {WindowPlacement | null}
|
|
28
|
+
*/
|
|
29
|
+
export function resolveHiddenSurfaceWindow(windows, session, options = {}) {
|
|
30
|
+
const terminalKey = canonicalGhosttyTerminalKey(session);
|
|
31
|
+
if (!terminalKey) return null;
|
|
32
|
+
const hostPid = Number(options.resolveHostPid?.(session) ?? 0);
|
|
33
|
+
if (!Number.isInteger(hostPid) || hostPid <= 0) return null;
|
|
34
|
+
const appId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
|
|
35
|
+
const hostWindows = windows.filter(
|
|
36
|
+
(window) =>
|
|
37
|
+
Number.isInteger(window?.id) &&
|
|
38
|
+
window.pid === hostPid &&
|
|
39
|
+
String(window.app_id ?? "") === appId,
|
|
40
|
+
);
|
|
41
|
+
if (hostWindows.length === 1) return { window: hostWindows[0], placement: "host" };
|
|
42
|
+
if (hostWindows.length === 0) return null;
|
|
43
|
+
for (const key of [terminalKey, sessionBindingKey(options.sessionToken)].filter(Boolean)) {
|
|
44
|
+
const binding = options.lookupBinding?.(key);
|
|
45
|
+
if (!binding) continue;
|
|
46
|
+
const remembered = hostWindows.find(
|
|
47
|
+
(window) => window.id === binding.windowId && window.pid === binding.windowPid,
|
|
48
|
+
);
|
|
49
|
+
if (remembered) return { window: remembered, placement: "binding" };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {SurfacePlacement | undefined} placement */
|
|
55
|
+
export function describePlacement(placement) {
|
|
56
|
+
if (placement === "host") return "hidden tab inside its Ghostty process's only window";
|
|
57
|
+
if (placement === "binding") return "hidden tab in the window that last displayed it";
|
|
58
|
+
return "visible tab";
|
|
59
|
+
}
|