@tryinget/pi-activity-strip 0.5.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 (41) hide show
  1. package/README.md +41 -6
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +22 -1
  4. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/src/app.rs +20 -20
  7. package/native/panel/src/card_view.rs +308 -9
  8. package/native/panel/src/protocol.rs +63 -0
  9. package/native/panel/src/runtime.rs +50 -9
  10. package/native/panel/src/style.css +134 -60
  11. package/package.json +4 -4
  12. package/src/common/agent-identity.mjs +163 -0
  13. package/src/common/ak-tasks.mjs +354 -0
  14. package/src/common/claude-events.mjs +190 -0
  15. package/src/common/claude-hook-config.mjs +56 -0
  16. package/src/common/claude-transcript.mjs +163 -0
  17. package/src/common/codex-transcript.mjs +153 -0
  18. package/src/common/compatibility.mjs +13 -0
  19. package/src/common/contracts.d.ts +26 -0
  20. package/src/common/contracts.ts +64 -1
  21. package/src/common/ghostty-present.mjs +100 -0
  22. package/src/common/ghostty-theme.mjs +149 -0
  23. package/src/common/niri-focus.mjs +179 -110
  24. package/src/common/session-cards.mjs +5 -2
  25. package/src/common/status-report.mjs +51 -0
  26. package/src/common/surface-bindings.mjs +234 -0
  27. package/src/common/terminal-identity.mjs +75 -12
  28. package/src/common/window-placement.mjs +59 -0
  29. package/src/common/workspace-view.mjs +161 -0
  30. package/src/native/agent-discovery.mjs +294 -0
  31. package/src/native/ak-runtime.mjs +164 -0
  32. package/src/native/codex-discovery.mjs +128 -0
  33. package/src/native/ghostty-tab-inventory.py +99 -0
  34. package/src/native/height-repair.mjs +154 -0
  35. package/src/native/main.mjs +120 -14
  36. package/src/native/panel-projection.mjs +67 -5
  37. package/src/native/placement.mjs +225 -0
  38. package/src/native/tab-inventory.mjs +188 -0
  39. package/src/native/theme-runtime.mjs +72 -0
  40. package/src/native/theme-source.mjs +197 -0
  41. package/src/native/workspace-events.mjs +16 -1
@@ -0,0 +1,163 @@
1
+ // ---
2
+ // summary: "derives live Claude Code session state from the tail of its transcript"
3
+ // read_when:
4
+ // - "changing Claude Code card telemetry or the transcript records it depends on"
5
+ // ---
6
+
7
+ /**
8
+ * Claude Code appends newline-delimited records to a per-session transcript. The format is internal
9
+ * to Claude Code and carries no stability guarantee, so every field is optional here and a record
10
+ * that does not parse is skipped. When nothing usable is found the caller keeps the process-only
11
+ * card rather than inventing activity.
12
+ */
13
+
14
+ const PREVIEW_LIMIT = 100;
15
+ const DETAIL_LIMIT = 100;
16
+
17
+ /** @typedef {"idle" | "thinking" | "tool"} ClaudeState */
18
+ /**
19
+ * @typedef {{
20
+ * title: string; lastPrompt: string; assistantPreview: string; state: ClaudeState;
21
+ * toolName: string; toolTarget: string; turnIndex: number; lastEventAt: number;
22
+ * mode: string; permissionMode: string; sessionId: string; cwd: string; gitBranch: string;
23
+ * }} ClaudeTelemetry
24
+ */
25
+
26
+ /** @param {unknown} value @param {number} limit */
27
+ function preview(value, limit) {
28
+ const text = String(value ?? "")
29
+ .replace(/\s+/g, " ")
30
+ .trim();
31
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
32
+ }
33
+
34
+ /** @param {unknown} value */
35
+ function timestampMs(value) {
36
+ const parsed = Date.parse(String(value ?? ""));
37
+ return Number.isFinite(parsed) ? parsed : 0;
38
+ }
39
+
40
+ /** @param {any} record */
41
+ function contentBlocks(record) {
42
+ const content = record?.message?.content;
43
+ return Array.isArray(content) ? content : [];
44
+ }
45
+
46
+ /**
47
+ * Name the thing a tool call is acting on, preferring the fields that read well on a card.
48
+ * @param {any} block
49
+ */
50
+ export function describeToolTarget(block) {
51
+ const input = block?.input ?? {};
52
+ for (const key of ["description", "command", "file_path", "path", "pattern", "query", "url"]) {
53
+ const value = input?.[key];
54
+ if (typeof value === "string" && value.trim()) return preview(value, DETAIL_LIMIT);
55
+ }
56
+ return "";
57
+ }
58
+
59
+ /** @returns {ClaudeTelemetry} */
60
+ function emptyTelemetry() {
61
+ return {
62
+ title: "",
63
+ lastPrompt: "",
64
+ assistantPreview: "",
65
+ state: "idle",
66
+ toolName: "",
67
+ toolTarget: "",
68
+ turnIndex: 0,
69
+ lastEventAt: 0,
70
+ mode: "",
71
+ permissionMode: "",
72
+ sessionId: "",
73
+ cwd: "",
74
+ gitBranch: "",
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Read the tail of a transcript. The first line is usually a fragment and is skipped by the parse
80
+ * failure path. State comes from the newest record that carries a timestamp, because the latched
81
+ * records Claude Code rewrites after every turn have none and would otherwise mask real activity.
82
+ * @param {string} tail
83
+ * @returns {ClaudeTelemetry}
84
+ */
85
+ export function parseClaudeTranscriptTail(tail) {
86
+ const telemetry = emptyTelemetry();
87
+ if (!tail) return telemetry;
88
+
89
+ /** @type {any[]} */
90
+ const records = [];
91
+ for (const line of String(tail).split("\n")) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed.startsWith("{")) continue;
94
+ try {
95
+ records.push(JSON.parse(trimmed));
96
+ } catch {
97
+ // A truncated first line or a partially written last line is not evidence.
98
+ }
99
+ }
100
+ if (records.length === 0) return telemetry;
101
+
102
+ for (const record of records) {
103
+ switch (record?.type) {
104
+ case "ai-title":
105
+ if (typeof record.aiTitle === "string") telemetry.title = record.aiTitle;
106
+ break;
107
+ case "last-prompt":
108
+ telemetry.lastPrompt = preview(record.lastPrompt, PREVIEW_LIMIT);
109
+ break;
110
+ case "mode":
111
+ telemetry.mode = String(record.mode ?? "");
112
+ break;
113
+ case "permission-mode":
114
+ telemetry.permissionMode = String(record.permissionMode ?? "");
115
+ break;
116
+ case "system":
117
+ if (record.subtype === "turn_duration" && Number.isFinite(Number(record.messageCount))) {
118
+ telemetry.turnIndex = Number(record.messageCount);
119
+ }
120
+ break;
121
+ default:
122
+ break;
123
+ }
124
+ if (typeof record?.sessionId === "string") telemetry.sessionId = record.sessionId;
125
+ if (typeof record?.cwd === "string") telemetry.cwd = record.cwd;
126
+ if (typeof record?.gitBranch === "string") telemetry.gitBranch = record.gitBranch;
127
+ if (record?.type === "assistant") {
128
+ const text = contentBlocks(record)
129
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
130
+ .map((block) => block.text)
131
+ .join(" ");
132
+ if (text.trim()) telemetry.assistantPreview = preview(text, PREVIEW_LIMIT);
133
+ }
134
+ }
135
+
136
+ const timed = records.filter(
137
+ (record) => record?.type !== "attachment" && timestampMs(record?.timestamp) > 0,
138
+ );
139
+ const latest = timed.at(-1);
140
+ if (!latest) return telemetry;
141
+ telemetry.lastEventAt = timestampMs(latest.timestamp);
142
+
143
+ if (latest.type === "system" && latest.subtype === "turn_duration") {
144
+ telemetry.state = "idle";
145
+ return telemetry;
146
+ }
147
+ if (latest.type === "assistant") {
148
+ const toolBlock = contentBlocks(latest).find((block) => block?.type === "tool_use");
149
+ if (toolBlock) {
150
+ telemetry.state = "tool";
151
+ telemetry.toolName = String(toolBlock.name ?? "");
152
+ telemetry.toolTarget = describeToolTarget(toolBlock);
153
+ return telemetry;
154
+ }
155
+ telemetry.state = latest.message?.stop_reason === "end_turn" ? "idle" : "thinking";
156
+ return telemetry;
157
+ }
158
+ if (latest.type === "user") {
159
+ telemetry.state = "thinking";
160
+ return telemetry;
161
+ }
162
+ return telemetry;
163
+ }
@@ -0,0 +1,153 @@
1
+ // ---
2
+ // summary: "derives live Codex session state from the tail of its rollout file"
3
+ // read_when:
4
+ // - "changing Codex card telemetry or the rollout records it depends on"
5
+ // ---
6
+
7
+ /**
8
+ * Codex appends newline-delimited records to a per-session rollout. Every record carries a
9
+ * top-level `timestamp`, a `type` of `session_meta`, `turn_context`, `event_msg` or
10
+ * `response_item`, and a `payload` whose own `type` names the event. The format belongs to Codex
11
+ * and carries no stability guarantee, so every field is optional here and an unparseable record is
12
+ * skipped. When nothing usable is found the caller keeps the process-only card.
13
+ */
14
+
15
+ const PREVIEW_LIMIT = 100;
16
+ const TOOL_ARGUMENT_KEYS = ["cmd", "command", "query", "path", "file_path", "pattern", "workdir"];
17
+
18
+ /** @typedef {import("./claude-transcript.mjs").ClaudeTelemetry} AgentTelemetry */
19
+
20
+ /** @param {unknown} value */
21
+ function preview(value) {
22
+ const text = String(value ?? "")
23
+ .replace(/\s+/g, " ")
24
+ .trim();
25
+ return text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT - 1)}…` : text;
26
+ }
27
+
28
+ /** @param {unknown} value */
29
+ function timestampMs(value) {
30
+ const parsed = Date.parse(String(value ?? ""));
31
+ return Number.isFinite(parsed) ? parsed : 0;
32
+ }
33
+
34
+ /**
35
+ * Name what a Codex tool call is acting on. Arguments arrive as a JSON string, and a call whose
36
+ * arguments do not parse still reports its tool name.
37
+ * @param {any} payload
38
+ */
39
+ export function describeCodexToolTarget(payload) {
40
+ let args = payload?.arguments;
41
+ if (typeof args === "string") {
42
+ try {
43
+ args = JSON.parse(args);
44
+ } catch {
45
+ return preview(args);
46
+ }
47
+ }
48
+ if (!args || typeof args !== "object") return "";
49
+ for (const key of TOOL_ARGUMENT_KEYS) {
50
+ const value = args[key];
51
+ if (typeof value === "string" && value.trim()) return preview(value);
52
+ if (Array.isArray(value) && value.length > 0) return preview(value.join(" "));
53
+ }
54
+ return "";
55
+ }
56
+
57
+ /** @param {any} payload */
58
+ function toolNameFor(payload) {
59
+ const type = String(payload?.type ?? "");
60
+ if (type === "function_call" || type === "custom_tool_call") {
61
+ return String(payload?.name ?? "") || "tool";
62
+ }
63
+ if (type === "local_shell_call") return "shell";
64
+ if (type === "web_search_call") return "web_search";
65
+ return "";
66
+ }
67
+
68
+ /** @returns {AgentTelemetry} */
69
+ function emptyTelemetry() {
70
+ return {
71
+ title: "",
72
+ lastPrompt: "",
73
+ assistantPreview: "",
74
+ state: "idle",
75
+ toolName: "",
76
+ toolTarget: "",
77
+ turnIndex: 0,
78
+ lastEventAt: 0,
79
+ mode: "",
80
+ permissionMode: "",
81
+ sessionId: "",
82
+ cwd: "",
83
+ gitBranch: "",
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Read the tail of a rollout. The first line is usually a fragment and is skipped by the parse
89
+ * failure path. State comes from the newest record that carries a timestamp, so a tool call is
90
+ * reported as running only while it is still the newest record; its output lands after it.
91
+ * @param {string} tail
92
+ * @returns {AgentTelemetry}
93
+ */
94
+ export function parseCodexRolloutTail(tail) {
95
+ const telemetry = emptyTelemetry();
96
+ if (!tail) return telemetry;
97
+
98
+ /** @type {any[]} */
99
+ const records = [];
100
+ for (const line of String(tail).split("\n")) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed.startsWith("{")) continue;
103
+ try {
104
+ records.push(JSON.parse(trimmed));
105
+ } catch {
106
+ // A truncated first or last line is not evidence.
107
+ }
108
+ }
109
+ if (records.length === 0) return telemetry;
110
+
111
+ for (const record of records) {
112
+ const payload = record?.payload;
113
+ const payloadType = String(payload?.type ?? "");
114
+ if (record?.type === "session_meta") {
115
+ telemetry.sessionId = String(payload?.id ?? telemetry.sessionId);
116
+ telemetry.cwd = String(payload?.cwd ?? telemetry.cwd);
117
+ const branch = payload?.git?.branch;
118
+ if (typeof branch === "string") telemetry.gitBranch = branch;
119
+ } else if (record?.type === "turn_context") {
120
+ telemetry.cwd = String(payload?.cwd ?? telemetry.cwd);
121
+ telemetry.mode = String(payload?.approval_policy ?? telemetry.mode);
122
+ const sandbox = payload?.sandbox_policy?.type;
123
+ if (typeof sandbox === "string") telemetry.permissionMode = sandbox;
124
+ } else if (payloadType === "user_message") {
125
+ telemetry.lastPrompt = preview(payload?.message);
126
+ } else if (payloadType === "agent_message") {
127
+ telemetry.assistantPreview = preview(payload?.message);
128
+ } else if (payloadType === "task_complete") {
129
+ telemetry.turnIndex += 1;
130
+ if (payload?.last_agent_message) {
131
+ telemetry.assistantPreview = preview(payload.last_agent_message);
132
+ }
133
+ }
134
+ }
135
+
136
+ const timed = records.filter((record) => timestampMs(record?.timestamp) > 0);
137
+ const latest = timed.at(-1);
138
+ if (!latest) return telemetry;
139
+ telemetry.lastEventAt = timestampMs(latest.timestamp);
140
+
141
+ const payload = latest.payload;
142
+ const payloadType = String(payload?.type ?? "");
143
+ // A tool call that is the newest record is still running: its output would appear after it.
144
+ const toolName = toolNameFor(payload);
145
+ if (toolName) {
146
+ telemetry.state = "tool";
147
+ telemetry.toolName = toolName;
148
+ telemetry.toolTarget = describeCodexToolTarget(payload);
149
+ return telemetry;
150
+ }
151
+ telemetry.state = payloadType === "task_complete" ? "idle" : "thinking";
152
+ return telemetry;
153
+ }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
+ import { detectTabInventorySupport } from "../native/tab-inventory.mjs";
9
10
 
10
11
  const execFileAsync = promisify(execFile);
11
12
 
@@ -83,6 +84,10 @@ export async function assessActivityStripCompatibility(options = {}) {
83
84
  env,
84
85
  execFileAsyncImpl: options.execFileAsyncImpl,
85
86
  });
87
+ const tabInventory = await detectTabInventorySupport({
88
+ execFileAsync: options.execFileAsyncImpl ?? execFileAsync,
89
+ env,
90
+ });
86
91
  const blockers = [];
87
92
  const warnings = [];
88
93
 
@@ -117,6 +122,12 @@ export async function assessActivityStripCompatibility(options = {}) {
117
122
  );
118
123
  }
119
124
 
125
+ if (windowManager === "niri" && !tabInventory.available) {
126
+ warnings.push(
127
+ `Hidden Ghostty tabs that were never shown cannot be placed: ${tabInventory.detail}. Tabs shown while the strip runs are still remembered.`,
128
+ );
129
+ }
130
+
120
131
  return {
121
132
  ok: blockers.length === 0,
122
133
  backend,
@@ -126,6 +137,7 @@ export async function assessActivityStripCompatibility(options = {}) {
126
137
  alignmentMode: "layer-shell",
127
138
  primaryDisplayOnly: true,
128
139
  clickThroughDefault: env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
140
+ tabInventory,
129
141
  blockers,
130
142
  warnings,
131
143
  };
@@ -143,6 +155,7 @@ export function formatCompatibilityReport(report) {
143
155
  `Alignment mode: ${report.alignmentMode}`,
144
156
  `Primary-display only: ${report.primaryDisplayOnly ? "yes" : "no"}`,
145
157
  `Click-through mode: ${report.clickThroughDefault ? "enabled by environment" : "disabled (interactive default)"}`,
158
+ `Hidden-tab inventory: ${report.tabInventory?.available ? "available" : "unavailable"} (${report.tabInventory?.detail ?? "unknown"})`,
146
159
  ];
147
160
 
148
161
  if (typeof report.displayCount === "number") {
@@ -38,6 +38,32 @@ export interface SessionSnapshot {
38
38
  errorMessage: string;
39
39
  }
40
40
 
41
+ export type AkTaskChipState = "active" | "deferred" | "orphaned";
42
+
43
+ /** One read-only AK task reference joined onto a terminal card by the native projection. */
44
+ export interface AkTaskChip {
45
+ id: number;
46
+ title: string;
47
+ state: AkTaskChipState;
48
+ }
49
+
50
+ /** A live-lease task claim parsed from read-only `ak task list` output. */
51
+ export interface AkTaskClaim {
52
+ id: number;
53
+ title: string;
54
+ repo: string;
55
+ sessionId: string;
56
+ leaseExpiresAt: number;
57
+ claimedAt: number;
58
+ }
59
+
60
+ /** A task with an active deferral parsed from read-only `ak task deferred` output. */
61
+ export interface AkTaskDeferred {
62
+ id: number;
63
+ title: string;
64
+ repo: string;
65
+ }
66
+
41
67
  export interface BrokerSnapshot {
42
68
  generatedAt: number;
43
69
  sessions: SessionSnapshot[];
@@ -19,6 +19,12 @@ export interface SessionSnapshot {
19
19
  publisherCount?: number;
20
20
  publisherIds?: string[];
21
21
  publisherRecordKeys?: string[];
22
+ publisherSessionIds?: string[];
23
+ akTasks?: AkTaskChip[];
24
+ akTaskOverflow?: number;
25
+ windowId?: number | null;
26
+ placement?: "title" | "host" | "binding";
27
+ surfaceVisible?: boolean;
22
28
  cwd: string;
23
29
  repoLabel: string;
24
30
  sessionName: string;
@@ -38,6 +44,32 @@ export interface SessionSnapshot {
38
44
  errorMessage: string;
39
45
  }
40
46
 
47
+ export type AkTaskChipState = "active" | "deferred" | "orphaned";
48
+
49
+ /** One read-only AK task reference joined onto a terminal card by the native projection. */
50
+ export interface AkTaskChip {
51
+ id: number;
52
+ title: string;
53
+ state: AkTaskChipState;
54
+ }
55
+
56
+ /** A live-lease task claim parsed from read-only `ak task list` output. */
57
+ export interface AkTaskClaim {
58
+ id: number;
59
+ title: string;
60
+ repo: string;
61
+ sessionId: string;
62
+ leaseExpiresAt: number;
63
+ claimedAt: number;
64
+ }
65
+
66
+ /** A task with an active deferral parsed from read-only `ak task deferred` output. */
67
+ export interface AkTaskDeferred {
68
+ id: number;
69
+ title: string;
70
+ repo: string;
71
+ }
72
+
41
73
  export interface BrokerSnapshot {
42
74
  generatedAt: number;
43
75
  sessions: SessionSnapshot[];
@@ -57,6 +89,30 @@ export interface ActivityStripRuntimeStatus {
57
89
  alignmentMode?: "layer-shell";
58
90
  rendererCardCount?: number;
59
91
  rendererCardIds?: string[];
92
+ rendererHiddenTabCardCount?: number;
93
+ surfaceBindingCount?: number;
94
+ unplacedSurfaceCount?: number;
95
+ agentTabCount?: number;
96
+ themeScheme?: "dark" | "light" | "unknown";
97
+ themeName?: string | null;
98
+ themeSource?: string | null;
99
+ themeFingerprint?: string;
100
+ themeError?: string;
101
+ agentScanError?: string | null;
102
+ heightRepairState?: "enabled" | "disabled";
103
+ heightRepairCount?: number;
104
+ lastHeightRepairAt?: number;
105
+ tabInventoryState?: "pending" | "ready" | "failed" | "unavailable" | "disabled";
106
+ tabInventoryDetail?: string | null;
107
+ tabInventoryFrameCount?: number;
108
+ tabInventoryTabCount?: number;
109
+ tabInventoryProbedAt?: number | null;
110
+ akTaskState?: "pending" | "ok" | "unavailable" | "disabled";
111
+ akTaskError?: string | null;
112
+ akTaskClaimCount?: number;
113
+ akTaskDeferredCount?: number;
114
+ akTaskOrphanCount?: number;
115
+ akTaskRenderedCount?: number;
60
116
  rendererVisibilityTransitionCount?: number;
61
117
  controllerPid?: number;
62
118
  panelPid?: number | null;
@@ -98,7 +154,13 @@ export interface ActivityStripBrokerOptions {
98
154
  remove(sessionId: string, publisherId?: string): boolean;
99
155
  };
100
156
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
101
- focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
157
+ focusSession?: (targetId: string) => Promise<{
158
+ ok: boolean;
159
+ error?: string;
160
+ windowId?: number;
161
+ presented?: boolean;
162
+ verified?: boolean;
163
+ }>;
102
164
  focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
103
165
  }
104
166
 
@@ -196,6 +258,7 @@ export interface ActivityStripCompatibilityReport {
196
258
  alignmentMode: "layer-shell";
197
259
  primaryDisplayOnly: boolean;
198
260
  clickThroughDefault: boolean;
261
+ tabInventory?: { available: boolean; detail: string };
199
262
  blockers: string[];
200
263
  warnings: string[];
201
264
  }
@@ -0,0 +1,100 @@
1
+ // ---
2
+ // summary: "presents one Ghostty surface through the running process's session-bus present-surface action"
3
+ // read_when:
4
+ // - "changing hidden-tab activation, Ghostty D-Bus targeting, or its fail-closed conditions"
5
+ // ---
6
+
7
+ import { normalizeGhosttySurfaceId } from "./terminal-identity.mjs";
8
+
9
+ const GHOSTTY_OBJECT_PATHS = new Map([
10
+ ["main", "/com/mitchellh/ghostty"],
11
+ ["legacy", "/com/tryinget/ghosttysidequest"],
12
+ ]);
13
+ export const GHOSTTY_PRESENT_SURFACE_TIMEOUT_MS = 2000;
14
+
15
+ /** @typedef {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} ExecFileAsync */
16
+ /** @typedef {{ok: true; busName: string} | {ok: false; error: string}} PresentSurfaceResult */
17
+
18
+ /** @param {unknown} family */
19
+ export function ghosttyObjectPathForFamily(family) {
20
+ return GHOSTTY_OBJECT_PATHS.get(String(family ?? "")) ?? "";
21
+ }
22
+
23
+ /**
24
+ * Every running Ghostty process, single-instance server or standalone window, exports
25
+ * `org.gtk.Actions` on its unique bus name. `present-surface` selects the tab owning the surface
26
+ * and presents its window; a surface id the process does not own is ignored by Ghostty itself.
27
+ * The target is the exact host process already proven to contain the surface, never a
28
+ * well-known name that another build could claim.
29
+ * @param {{execFileAsync: ExecFileAsync; env?: NodeJS.ProcessEnv; hostPid: number; terminalFamily: unknown; surfaceId: unknown; timeout?: number}} options
30
+ * @returns {Promise<PresentSurfaceResult>}
31
+ */
32
+ export async function presentGhosttySurface({
33
+ execFileAsync,
34
+ env = process.env,
35
+ hostPid,
36
+ terminalFamily,
37
+ surfaceId,
38
+ timeout = GHOSTTY_PRESENT_SURFACE_TIMEOUT_MS,
39
+ }) {
40
+ const objectPath = ghosttyObjectPathForFamily(terminalFamily);
41
+ const normalizedSurfaceId = normalizeGhosttySurfaceId(surfaceId);
42
+ if (
43
+ !objectPath ||
44
+ !normalizedSurfaceId ||
45
+ normalizedSurfaceId === "0" ||
46
+ !Number.isInteger(hostPid) ||
47
+ hostPid <= 0
48
+ ) {
49
+ return { ok: false, error: "the surface target is incomplete" };
50
+ }
51
+
52
+ let rows;
53
+ try {
54
+ const { stdout } = await execFileAsync(
55
+ "busctl",
56
+ ["--user", "list", "--no-pager", "--no-legend"],
57
+ { env, timeout },
58
+ );
59
+ rows = String(stdout ?? "")
60
+ .split("\n")
61
+ .map((line) => line.trim().split(/\s+/));
62
+ } catch {
63
+ return { ok: false, error: "the session bus is unavailable" };
64
+ }
65
+ const uniqueNames = rows
66
+ .filter(
67
+ (fields) => fields[0]?.startsWith(":") && Number.parseInt(fields[1] ?? "", 10) === hostPid,
68
+ )
69
+ .map((fields) => String(fields[0]));
70
+ if (uniqueNames.length === 0) {
71
+ return { ok: false, error: "the Ghostty process is not on the session bus" };
72
+ }
73
+ if (uniqueNames.length > 1) {
74
+ return { ok: false, error: "the Ghostty process owns several bus connections" };
75
+ }
76
+
77
+ try {
78
+ await execFileAsync(
79
+ "busctl",
80
+ [
81
+ "--user",
82
+ "call",
83
+ uniqueNames[0],
84
+ objectPath,
85
+ "org.gtk.Actions",
86
+ "Activate",
87
+ "sava{sv}",
88
+ "present-surface",
89
+ "1",
90
+ "t",
91
+ normalizedSurfaceId,
92
+ "0",
93
+ ],
94
+ { env, timeout },
95
+ );
96
+ return { ok: true, busName: uniqueNames[0] };
97
+ } catch {
98
+ return { ok: false, error: "Ghostty rejected present-surface" };
99
+ }
100
+ }