@tryinget/pi-activity-strip 0.4.0 → 0.5.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 +96 -152
- package/bin/pi-activity-strip.mjs +31 -62
- 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 +303 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +234 -0
- package/native/panel/src/runtime.rs +110 -0
- package/native/panel/src/style.css +176 -0
- package/package.json +13 -1
- 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/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +5 -0
- package/src/common/compatibility.mjs +15 -13
- package/src/common/constants.mjs +10 -28
- package/src/common/contracts.d.ts +42 -4
- package/src/common/contracts.ts +42 -4
- package/src/common/niri-focus.mjs +79 -79
- package/src/common/protocol.mjs +48 -16
- package/src/common/session-cards.mjs +102 -0
- package/src/common/status-report.mjs +2 -1
- package/src/common/telemetry.mjs +8 -1
- package/src/common/terminal-identity.mjs +149 -0
- package/src/native/main.mjs +393 -0
- package/src/native/panel-projection.mjs +99 -0
- package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
- 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
|
@@ -1,35 +1,9 @@
|
|
|
1
1
|
// ---
|
|
2
|
-
// summary: "coalesces
|
|
2
|
+
// summary: "coalesces asynchronous reconciliation so stale work cannot publish over newer state"
|
|
3
3
|
// read_when:
|
|
4
|
-
// - "changing
|
|
4
|
+
// - "changing latest-only native workspace reconciliation"
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown } } | null | undefined} window
|
|
9
|
-
*/
|
|
10
|
-
export function hasNiriFloatingPosition(window) {
|
|
11
|
-
const position = window?.layout?.tile_pos_in_workspace_view;
|
|
12
|
-
return window?.is_floating === true && Array.isArray(position) && position.length >= 2;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown, window_size?: unknown } } | null | undefined} window
|
|
17
|
-
* @param {{x: number, y: number, width: number, height: number}} target
|
|
18
|
-
*/
|
|
19
|
-
export function isNiriWindowAligned(window, target) {
|
|
20
|
-
if (!hasNiriFloatingPosition(window)) return false;
|
|
21
|
-
const position = window?.layout?.tile_pos_in_workspace_view;
|
|
22
|
-
const size = window?.layout?.window_size;
|
|
23
|
-
return Boolean(
|
|
24
|
-
Array.isArray(position) &&
|
|
25
|
-
Array.isArray(size) &&
|
|
26
|
-
Number(size[0]) === target.width &&
|
|
27
|
-
Number(size[1]) === target.height &&
|
|
28
|
-
Math.abs(Number(position[0]) - target.x) < 1 &&
|
|
29
|
-
Math.abs(Number(position[1]) - target.y) < 1,
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
7
|
/**
|
|
34
8
|
* @param {(attempt: { generation: number, isCurrent: () => boolean }) => Promise<void>} run
|
|
35
9
|
*/
|
|
@@ -37,34 +11,42 @@ export function createLatestOnlyRunner(run) {
|
|
|
37
11
|
if (typeof run !== "function") throw new TypeError("run must be a function");
|
|
38
12
|
|
|
39
13
|
let requestedGeneration = 0;
|
|
14
|
+
let completedGeneration = 0;
|
|
40
15
|
/** @type {Promise<void> | null} */
|
|
41
16
|
let worker = null;
|
|
42
17
|
|
|
18
|
+
function ensureWorker() {
|
|
19
|
+
if (!worker) worker = drain();
|
|
20
|
+
}
|
|
21
|
+
|
|
43
22
|
async function drain() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
23
|
+
try {
|
|
24
|
+
while (completedGeneration < requestedGeneration) {
|
|
25
|
+
const generation = requestedGeneration;
|
|
26
|
+
const isCurrent = () => generation === requestedGeneration;
|
|
27
|
+
try {
|
|
28
|
+
await run({ generation, isCurrent });
|
|
29
|
+
} catch {
|
|
30
|
+
// Alignment is best effort. A newer request must still be allowed to reconcile the window.
|
|
31
|
+
}
|
|
32
|
+
completedGeneration = generation;
|
|
51
33
|
}
|
|
52
|
-
|
|
34
|
+
} finally {
|
|
35
|
+
worker = null;
|
|
36
|
+
// A request can arrive after the loop condition was checked but before this finally block.
|
|
37
|
+
// Hand it to a successor synchronously so no requested generation is lost.
|
|
38
|
+
if (completedGeneration < requestedGeneration) ensureWorker();
|
|
53
39
|
}
|
|
54
40
|
}
|
|
55
41
|
|
|
56
42
|
function request() {
|
|
57
43
|
requestedGeneration += 1;
|
|
58
|
-
|
|
59
|
-
worker = drain().finally(() => {
|
|
60
|
-
worker = null;
|
|
61
|
-
});
|
|
62
|
-
}
|
|
44
|
+
ensureWorker();
|
|
63
45
|
return requestedGeneration;
|
|
64
46
|
}
|
|
65
47
|
|
|
66
48
|
async function waitForIdle() {
|
|
67
|
-
await worker;
|
|
49
|
+
while (worker) await worker;
|
|
68
50
|
}
|
|
69
51
|
|
|
70
52
|
return {
|
|
@@ -46,3 +46,8 @@ export function disambiguatedRepoLabel(session, duplicateLabels) {
|
|
|
46
46
|
? `${label} · ${String(processId).slice(-4)}`
|
|
47
47
|
: label;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** @param {{hovered?: boolean; activeElement?: boolean; documentFocused?: boolean}} state */
|
|
51
|
+
export function shouldRetainExpandedCard({ hovered, activeElement, documentFocused }) {
|
|
52
|
+
return Boolean(hovered || (documentFocused && activeElement));
|
|
53
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// ---
|
|
2
|
-
// summary: "detects
|
|
2
|
+
// summary: "detects native layer-shell prerequisites and formats actionable activity-strip compatibility reports"
|
|
3
3
|
// read_when:
|
|
4
4
|
// - "changing host detection, compatibility blockers, warnings, or doctor output"
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
-
import { locateElectron } from "./electron.mjs";
|
|
10
9
|
|
|
11
10
|
const execFileAsync = promisify(execFile);
|
|
12
11
|
|
|
@@ -67,14 +66,17 @@ export async function detectDisplayCount(options = {}) {
|
|
|
67
66
|
/**
|
|
68
67
|
* @param {{
|
|
69
68
|
* env?: NodeJS.ProcessEnv;
|
|
70
|
-
* locateElectronImpl?: typeof locateElectron;
|
|
71
69
|
* execFileAsyncImpl?: typeof execFileAsync;
|
|
70
|
+
* platform?: NodeJS.Platform;
|
|
71
|
+
* arch?: string;
|
|
72
72
|
* }} [options]
|
|
73
73
|
* @returns {Promise<import("./contracts.ts").ActivityStripCompatibilityReport>}
|
|
74
74
|
*/
|
|
75
75
|
export async function assessActivityStripCompatibility(options = {}) {
|
|
76
76
|
const env = options.env || process.env;
|
|
77
|
-
const
|
|
77
|
+
const backend = "native";
|
|
78
|
+
const platform = options.platform ?? process.platform;
|
|
79
|
+
const arch = options.arch ?? process.arch;
|
|
78
80
|
const displayServer = detectDisplayServer(env);
|
|
79
81
|
const windowManager = detectWindowManager(env);
|
|
80
82
|
const displayCount = await detectDisplayCount({
|
|
@@ -83,7 +85,6 @@ export async function assessActivityStripCompatibility(options = {}) {
|
|
|
83
85
|
});
|
|
84
86
|
const blockers = [];
|
|
85
87
|
const warnings = [];
|
|
86
|
-
let electronPath = null;
|
|
87
88
|
|
|
88
89
|
if (displayServer === "headless") {
|
|
89
90
|
blockers.push(
|
|
@@ -97,10 +98,11 @@ export async function assessActivityStripCompatibility(options = {}) {
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
101
|
+
if (displayServer !== "headless" && displayServer !== "wayland") {
|
|
102
|
+
blockers.push("The native Activity Strip panel requires a Wayland session.");
|
|
103
|
+
}
|
|
104
|
+
if (platform !== "linux" || arch !== "x64") {
|
|
105
|
+
blockers.push(`The packaged native panel requires Linux x64; detected ${platform} ${arch}.`);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
if (displayCount && displayCount > 1) {
|
|
@@ -111,17 +113,17 @@ export async function assessActivityStripCompatibility(options = {}) {
|
|
|
111
113
|
|
|
112
114
|
if (windowManager !== "niri") {
|
|
113
115
|
warnings.push(
|
|
114
|
-
"
|
|
116
|
+
"Workspace-local projection is optimized for Niri; other layer-shell compositors show the global card view.",
|
|
115
117
|
);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
return {
|
|
119
121
|
ok: blockers.length === 0,
|
|
122
|
+
backend,
|
|
120
123
|
displayServer,
|
|
121
124
|
windowManager: typeof windowManager === "string" ? windowManager : null,
|
|
122
|
-
electronPath,
|
|
123
125
|
displayCount,
|
|
124
|
-
alignmentMode:
|
|
126
|
+
alignmentMode: "layer-shell",
|
|
125
127
|
primaryDisplayOnly: true,
|
|
126
128
|
clickThroughDefault: env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
|
|
127
129
|
blockers,
|
|
@@ -137,7 +139,7 @@ export function formatCompatibilityReport(report) {
|
|
|
137
139
|
`Compatibility: ${report.ok ? "compatible" : "blocked"}`,
|
|
138
140
|
`Display server: ${report.displayServer}`,
|
|
139
141
|
`Window manager: ${report.windowManager || "unknown"}`,
|
|
140
|
-
`
|
|
142
|
+
`Backend: ${report.backend}`,
|
|
141
143
|
`Alignment mode: ${report.alignmentMode}`,
|
|
142
144
|
`Primary-display only: ${report.primaryDisplayOnly ? "yes" : "no"}`,
|
|
143
145
|
`Click-through mode: ${report.clickThroughDefault ? "enabled by environment" : "disabled (interactive default)"}`,
|
package/src/common/constants.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---
|
|
2
|
-
// summary: "defines shared
|
|
2
|
+
// summary: "defines shared broker paths, timing thresholds, and transport bounds"
|
|
3
3
|
// read_when:
|
|
4
4
|
// - "tuning activity-strip runtime defaults or locating shared configuration values"
|
|
5
5
|
// ---
|
|
@@ -8,40 +8,22 @@ import os from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
|
|
10
10
|
export const ACTIVITY_STRIP_NAME = "pi-activity-strip";
|
|
11
|
-
export const ACTIVITY_STRIP_SOCKET_DIR =
|
|
12
|
-
|
|
13
|
-
".pi",
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
);
|
|
18
|
-
export const ACTIVITY_STRIP_SOCKET_PATH = path.join(
|
|
19
|
-
ACTIVITY_STRIP_SOCKET_DIR,
|
|
20
|
-
"activity-strip.sock",
|
|
21
|
-
);
|
|
22
|
-
export const ACTIVITY_STRIP_WIDTH_PADDING = 16;
|
|
23
|
-
export const ACTIVITY_STRIP_HEIGHT = 84;
|
|
24
|
-
export const ACTIVITY_STRIP_EXPANDED_HEIGHT = 252;
|
|
11
|
+
export const ACTIVITY_STRIP_SOCKET_DIR =
|
|
12
|
+
process.env.PI_ACTIVITY_STRIP_SOCKET_DIR?.trim() ||
|
|
13
|
+
path.join(os.homedir(), ".pi", "agent", "state", ACTIVITY_STRIP_NAME);
|
|
14
|
+
export const ACTIVITY_STRIP_SOCKET_PATH =
|
|
15
|
+
process.env.PI_ACTIVITY_STRIP_SOCKET_PATH?.trim() ||
|
|
16
|
+
path.join(ACTIVITY_STRIP_SOCKET_DIR, "activity-strip.sock");
|
|
25
17
|
export const ACTIVITY_STRIP_ORDER_REFRESH_MS = 15_000;
|
|
26
18
|
export const ACTIVITY_STRIP_WORKSPACE_SYNC_MS = 1500;
|
|
27
|
-
export const ACTIVITY_STRIP_NIRI_ANIMATION_SETTLE_MS = 450;
|
|
28
19
|
export const ACTIVITY_STRIP_HEARTBEAT_MS = 2500;
|
|
29
20
|
export const ACTIVITY_STRIP_STALE_AFTER_MS = 12000;
|
|
21
|
+
export const ACTIVITY_STRIP_MAX_SESSIONS = 256;
|
|
22
|
+
export const ACTIVITY_STRIP_MAX_MESSAGE_BYTES = 256 * 1024;
|
|
23
|
+
export const ACTIVITY_STRIP_CLIENT_IDLE_TIMEOUT_MS = 2000;
|
|
30
24
|
export const ACTIVITY_STRIP_BROADCAST_TICK_MS = 1000;
|
|
31
25
|
export const ACTIVITY_STRIP_SEND_THROTTLE_MS = 120;
|
|
32
26
|
export const ACTIVITY_STRIP_CONNECT_TIMEOUT_MS = 450;
|
|
33
27
|
export const ACTIVITY_STRIP_START_TIMEOUT_MS = 5000;
|
|
34
|
-
export const DEFAULT_ELECTRON_CANDIDATES = [
|
|
35
|
-
"electron",
|
|
36
|
-
"electron39",
|
|
37
|
-
"electron38",
|
|
38
|
-
"electron37",
|
|
39
|
-
"electron36",
|
|
40
|
-
"electron35",
|
|
41
|
-
"electron34",
|
|
42
|
-
"electron33",
|
|
43
|
-
"electron32",
|
|
44
|
-
"electron31",
|
|
45
|
-
];
|
|
46
28
|
export const ACTIVITY_STRIP_EVENT_STALL_MS = 15 * 60_000;
|
|
47
29
|
export const ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS = [250, 750];
|
|
@@ -9,7 +9,16 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
|
|
|
9
9
|
export interface SessionSnapshot {
|
|
10
10
|
sessionId: string;
|
|
11
11
|
publisherId: string;
|
|
12
|
+
publisherSequence: number;
|
|
12
13
|
processId: number;
|
|
14
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
15
|
+
terminalKey: string;
|
|
16
|
+
terminalFamily: string;
|
|
17
|
+
terminalSurfaceId: string;
|
|
18
|
+
cardId?: string;
|
|
19
|
+
publisherCount?: number;
|
|
20
|
+
publisherIds?: string[];
|
|
21
|
+
publisherRecordKeys?: string[];
|
|
13
22
|
cwd: string;
|
|
14
23
|
repoLabel: string;
|
|
15
24
|
sessionName: string;
|
|
@@ -38,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
|
|
|
38
47
|
|
|
39
48
|
export interface ActivityStripRuntimeStatus {
|
|
40
49
|
state: ActivityStripRuntimeState;
|
|
50
|
+
backend?: "native-layer-shell";
|
|
41
51
|
startedAt: number;
|
|
42
52
|
readyAt?: number | null;
|
|
43
53
|
windowVisible?: boolean;
|
|
44
54
|
displayServer?: "wayland" | "x11" | "headless" | "unknown";
|
|
45
55
|
windowManager?: string | null;
|
|
46
56
|
displayCount?: number | null;
|
|
47
|
-
alignmentMode?: "
|
|
57
|
+
alignmentMode?: "layer-shell";
|
|
58
|
+
rendererCardCount?: number;
|
|
59
|
+
rendererCardIds?: string[];
|
|
60
|
+
rendererVisibilityTransitionCount?: number;
|
|
61
|
+
controllerPid?: number;
|
|
62
|
+
panelPid?: number | null;
|
|
63
|
+
panelExpanded?: boolean;
|
|
64
|
+
clickThrough?: boolean;
|
|
65
|
+
panelMoveCount?: number;
|
|
66
|
+
panelActivationCount?: number;
|
|
67
|
+
panelRestartCount?: number;
|
|
68
|
+
lastMovedCardId?: string | null;
|
|
48
69
|
warnings?: string[];
|
|
49
70
|
error?: string | null;
|
|
50
71
|
}
|
|
@@ -65,6 +86,7 @@ export interface BrokerClientOptions {
|
|
|
65
86
|
|
|
66
87
|
export interface SessionStoreOptions {
|
|
67
88
|
staleAfterMs?: number;
|
|
89
|
+
maxSessions?: number;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
export interface ActivityStripBrokerOptions {
|
|
@@ -76,7 +98,8 @@ export interface ActivityStripBrokerOptions {
|
|
|
76
98
|
remove(sessionId: string, publisherId?: string): boolean;
|
|
77
99
|
};
|
|
78
100
|
getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
|
|
79
|
-
focusSession?: (
|
|
101
|
+
focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
|
|
102
|
+
focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
export interface ToolCallDescription {
|
|
@@ -143,6 +166,21 @@ export interface SessionTelemetryOptions {
|
|
|
143
166
|
pi?: TelemetryPiLike;
|
|
144
167
|
cwd?: string;
|
|
145
168
|
sessionName?: string;
|
|
169
|
+
env?: NodeJS.ProcessEnv;
|
|
170
|
+
stdinIsTTY?: boolean;
|
|
171
|
+
resolveTerminalIdentity?: (options: {
|
|
172
|
+
env?: NodeJS.ProcessEnv;
|
|
173
|
+
hasUI?: boolean;
|
|
174
|
+
stdinIsTTY?: boolean;
|
|
175
|
+
ttyPath?: string;
|
|
176
|
+
processId?: number;
|
|
177
|
+
ancestorExecutable?: string;
|
|
178
|
+
}) => {
|
|
179
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
180
|
+
terminalKey: string;
|
|
181
|
+
terminalFamily: string;
|
|
182
|
+
terminalSurfaceId: string;
|
|
183
|
+
};
|
|
146
184
|
transport?: {
|
|
147
185
|
publish: (session: SessionSnapshot) => Promise<unknown>;
|
|
148
186
|
remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
|
|
@@ -151,11 +189,11 @@ export interface SessionTelemetryOptions {
|
|
|
151
189
|
|
|
152
190
|
export interface ActivityStripCompatibilityReport {
|
|
153
191
|
ok: boolean;
|
|
192
|
+
backend: string;
|
|
154
193
|
displayServer: "wayland" | "x11" | "headless" | "unknown";
|
|
155
194
|
windowManager: string | null;
|
|
156
|
-
electronPath: string | null;
|
|
157
195
|
displayCount: number | null;
|
|
158
|
-
alignmentMode: "
|
|
196
|
+
alignmentMode: "layer-shell";
|
|
159
197
|
primaryDisplayOnly: boolean;
|
|
160
198
|
clickThroughDefault: boolean;
|
|
161
199
|
blockers: string[];
|
package/src/common/contracts.ts
CHANGED
|
@@ -9,7 +9,16 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
|
|
|
9
9
|
export interface SessionSnapshot {
|
|
10
10
|
sessionId: string;
|
|
11
11
|
publisherId: string;
|
|
12
|
+
publisherSequence: number;
|
|
12
13
|
processId: number;
|
|
14
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
15
|
+
terminalKey: string;
|
|
16
|
+
terminalFamily: string;
|
|
17
|
+
terminalSurfaceId: string;
|
|
18
|
+
cardId?: string;
|
|
19
|
+
publisherCount?: number;
|
|
20
|
+
publisherIds?: string[];
|
|
21
|
+
publisherRecordKeys?: string[];
|
|
13
22
|
cwd: string;
|
|
14
23
|
repoLabel: string;
|
|
15
24
|
sessionName: string;
|
|
@@ -38,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
|
|
|
38
47
|
|
|
39
48
|
export interface ActivityStripRuntimeStatus {
|
|
40
49
|
state: ActivityStripRuntimeState;
|
|
50
|
+
backend?: "native-layer-shell";
|
|
41
51
|
startedAt: number;
|
|
42
52
|
readyAt?: number | null;
|
|
43
53
|
windowVisible?: boolean;
|
|
44
54
|
displayServer?: "wayland" | "x11" | "headless" | "unknown";
|
|
45
55
|
windowManager?: string | null;
|
|
46
56
|
displayCount?: number | null;
|
|
47
|
-
alignmentMode?: "
|
|
57
|
+
alignmentMode?: "layer-shell";
|
|
58
|
+
rendererCardCount?: number;
|
|
59
|
+
rendererCardIds?: string[];
|
|
60
|
+
rendererVisibilityTransitionCount?: number;
|
|
61
|
+
controllerPid?: number;
|
|
62
|
+
panelPid?: number | null;
|
|
63
|
+
panelExpanded?: boolean;
|
|
64
|
+
clickThrough?: boolean;
|
|
65
|
+
panelMoveCount?: number;
|
|
66
|
+
panelActivationCount?: number;
|
|
67
|
+
panelRestartCount?: number;
|
|
68
|
+
lastMovedCardId?: string | null;
|
|
48
69
|
warnings?: string[];
|
|
49
70
|
error?: string | null;
|
|
50
71
|
}
|
|
@@ -65,6 +86,7 @@ export interface BrokerClientOptions {
|
|
|
65
86
|
|
|
66
87
|
export interface SessionStoreOptions {
|
|
67
88
|
staleAfterMs?: number;
|
|
89
|
+
maxSessions?: number;
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
export interface ActivityStripBrokerOptions {
|
|
@@ -76,7 +98,8 @@ export interface ActivityStripBrokerOptions {
|
|
|
76
98
|
remove(sessionId: string, publisherId?: string): boolean;
|
|
77
99
|
};
|
|
78
100
|
getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
|
|
79
|
-
focusSession?: (
|
|
101
|
+
focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
|
|
102
|
+
focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
export interface ToolCallDescription {
|
|
@@ -143,6 +166,21 @@ export interface SessionTelemetryOptions {
|
|
|
143
166
|
pi?: TelemetryPiLike;
|
|
144
167
|
cwd?: string;
|
|
145
168
|
sessionName?: string;
|
|
169
|
+
env?: NodeJS.ProcessEnv;
|
|
170
|
+
stdinIsTTY?: boolean;
|
|
171
|
+
resolveTerminalIdentity?: (options: {
|
|
172
|
+
env?: NodeJS.ProcessEnv;
|
|
173
|
+
hasUI?: boolean;
|
|
174
|
+
stdinIsTTY?: boolean;
|
|
175
|
+
ttyPath?: string;
|
|
176
|
+
processId?: number;
|
|
177
|
+
ancestorExecutable?: string;
|
|
178
|
+
}) => {
|
|
179
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
180
|
+
terminalKey: string;
|
|
181
|
+
terminalFamily: string;
|
|
182
|
+
terminalSurfaceId: string;
|
|
183
|
+
};
|
|
146
184
|
transport?: {
|
|
147
185
|
publish: (session: SessionSnapshot) => Promise<unknown>;
|
|
148
186
|
remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
|
|
@@ -151,11 +189,11 @@ export interface SessionTelemetryOptions {
|
|
|
151
189
|
|
|
152
190
|
export interface ActivityStripCompatibilityReport {
|
|
153
191
|
ok: boolean;
|
|
192
|
+
backend: string;
|
|
154
193
|
displayServer: "wayland" | "x11" | "headless" | "unknown";
|
|
155
194
|
windowManager: string | null;
|
|
156
|
-
electronPath: string | null;
|
|
157
195
|
displayCount: number | null;
|
|
158
|
-
alignmentMode: "
|
|
196
|
+
alignmentMode: "layer-shell";
|
|
159
197
|
primaryDisplayOnly: boolean;
|
|
160
198
|
clickThroughDefault: boolean;
|
|
161
199
|
blockers: string[];
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
|
|
10
|
+
import {
|
|
11
|
+
appIdForGhosttyFamily,
|
|
12
|
+
canonicalGhosttyTerminalKey,
|
|
13
|
+
normalizeGhosttySurfaceId,
|
|
14
|
+
terminalTitleSegment,
|
|
15
|
+
} from "./terminal-identity.mjs";
|
|
9
16
|
|
|
10
17
|
const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
|
|
11
18
|
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;
|
|
@@ -92,11 +99,15 @@ export function shortSessionId(value, hexLength = SESSION_TITLE_TOKEN_HEX_LENGTH
|
|
|
92
99
|
.toLowerCase();
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
/** @param {Array<Record<string, unknown>>} sessions @param {string}
|
|
96
|
-
export function resolveSnapshotSession(sessions,
|
|
97
|
-
const requestedId = String(
|
|
98
|
-
const
|
|
99
|
-
|
|
102
|
+
/** @param {Array<Record<string, unknown>>} sessions @param {string} targetId */
|
|
103
|
+
export function resolveSnapshotSession(sessions, targetId) {
|
|
104
|
+
const requestedId = String(targetId ?? "");
|
|
105
|
+
const cardMatches = sessions.filter(
|
|
106
|
+
(session) => session?.cardId === requestedId || sessionCardId(session) === requestedId,
|
|
107
|
+
);
|
|
108
|
+
if (cardMatches.length === 1) return cardMatches[0];
|
|
109
|
+
const logicalMatches = sessions.filter((session) => session?.sessionId === requestedId);
|
|
110
|
+
return logicalMatches.length === 1 ? logicalMatches[0] : null;
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/**
|
|
@@ -105,8 +116,9 @@ export function resolveSnapshotSession(sessions, sessionId) {
|
|
|
105
116
|
* must also identify a known Ghostty build, and ambiguity always returns no match.
|
|
106
117
|
* @param {Array<Record<string, unknown>>} windows
|
|
107
118
|
* @param {string} sessionId
|
|
119
|
+
* @param {Record<string, unknown>} [session]
|
|
108
120
|
*/
|
|
109
|
-
export function resolveExactGhosttyWindow(windows, sessionId) {
|
|
121
|
+
export function resolveExactGhosttyWindow(windows, sessionId, session = {}) {
|
|
110
122
|
const fullId = String(sessionId ?? "").trim();
|
|
111
123
|
if (!PI_SESSION_ID.test(fullId)) return null;
|
|
112
124
|
|
|
@@ -123,6 +135,22 @@ export function resolveExactGhosttyWindow(windows, sessionId) {
|
|
|
123
135
|
};
|
|
124
136
|
|
|
125
137
|
const currentToken = shortSessionId(fullId);
|
|
138
|
+
if (session.terminalKind === "ghostty-surface" && !canonicalGhosttyTerminalKey(session)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const surfaceId = normalizeGhosttySurfaceId(session.terminalSurfaceId);
|
|
142
|
+
const titleSegment = terminalTitleSegment(session);
|
|
143
|
+
const expectedAppId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
|
|
144
|
+
if (surfaceId && titleSegment && expectedAppId) {
|
|
145
|
+
const surfaceSuffix = ` · ${titleSegment} · ${currentToken}`;
|
|
146
|
+
const surfaceMatches = windows.filter(
|
|
147
|
+
(window) =>
|
|
148
|
+
Number.isInteger(window?.id) &&
|
|
149
|
+
String(window?.app_id ?? "") === expectedAppId &&
|
|
150
|
+
String(window?.title ?? "").endsWith(surfaceSuffix),
|
|
151
|
+
);
|
|
152
|
+
return surfaceMatches.length === 1 ? surfaceMatches[0] : null;
|
|
153
|
+
}
|
|
126
154
|
const currentMatches = matchesForToken(currentToken);
|
|
127
155
|
if (currentMatches.length > 0) return currentMatches.length === 1 ? currentMatches[0] : null;
|
|
128
156
|
|
|
@@ -153,16 +181,15 @@ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {})
|
|
|
153
181
|
const matches = sessions.filter((session) => {
|
|
154
182
|
const sessionId = resolvePiSessionIdentity(session, options);
|
|
155
183
|
if (!sessionId) return false;
|
|
156
|
-
return resolveExactGhosttyWindow(windows, sessionId)?.id === focusedWindow.id;
|
|
184
|
+
return resolveExactGhosttyWindow(windows, sessionId, session)?.id === focusedWindow.id;
|
|
157
185
|
});
|
|
158
186
|
return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
/**
|
|
162
190
|
* Project the global broker snapshot onto one exact Niri workspace. Membership
|
|
163
|
-
* requires an exact
|
|
164
|
-
*
|
|
165
|
-
* not affect membership.
|
|
191
|
+
* requires an exact Ghostty window. Multiple publisher streams bound to the same terminal are
|
|
192
|
+
* aggregated into one stable card; ambiguity between distinct windows still fails closed.
|
|
166
193
|
* @param {Array<Record<string, unknown>>} windows
|
|
167
194
|
* @param {Record<string, unknown>} workspace
|
|
168
195
|
* @param {Array<Record<string, unknown>>} sessions
|
|
@@ -170,26 +197,57 @@ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {})
|
|
|
170
197
|
*/
|
|
171
198
|
export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
|
|
172
199
|
if (!Number.isInteger(workspace?.id)) return null;
|
|
173
|
-
/** @type {Array<{session: Record<string, unknown>; window: Record<string, unknown>}>} */
|
|
200
|
+
/** @type {Array<{cardId: string; session: Record<string, unknown>; window: Record<string, unknown>}>} */
|
|
174
201
|
const candidates = [];
|
|
175
202
|
for (const session of sessions) {
|
|
176
203
|
const sessionIdentity = resolvePiSessionIdentity(session, options);
|
|
177
204
|
if (!sessionIdentity) continue;
|
|
178
|
-
const window = resolveExactGhosttyWindow(windows, sessionIdentity);
|
|
205
|
+
const window = resolveExactGhosttyWindow(windows, sessionIdentity, session);
|
|
179
206
|
if (!window || window.workspace_id !== workspace.id) continue;
|
|
180
|
-
|
|
207
|
+
const cardId = sessionCardId(session);
|
|
208
|
+
if (!cardId) continue;
|
|
209
|
+
candidates.push({ cardId, session: { ...session, cardId }, window });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cardIdsByWindow = new Map();
|
|
213
|
+
for (const candidate of candidates) {
|
|
214
|
+
const ids = cardIdsByWindow.get(candidate.window.id) ?? new Set();
|
|
215
|
+
ids.add(candidate.cardId);
|
|
216
|
+
cardIdsByWindow.set(candidate.window.id, ids);
|
|
181
217
|
}
|
|
182
|
-
const
|
|
218
|
+
const allowedCardByWindow = new Map();
|
|
219
|
+
for (const [windowId, ids] of cardIdsByWindow) {
|
|
220
|
+
const terminalIds = [...ids].filter((cardId) => cardId.startsWith("terminal:"));
|
|
221
|
+
if (terminalIds.length === 1) allowedCardByWindow.set(windowId, terminalIds[0]);
|
|
222
|
+
else if (ids.size === 1) allowedCardByWindow.set(windowId, [...ids][0]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @type {Map<string, Array<{session: Record<string, unknown>; window: Record<string, unknown>}>>} */
|
|
226
|
+
const groups = new Map();
|
|
183
227
|
for (const candidate of candidates) {
|
|
184
|
-
|
|
228
|
+
if (allowedCardByWindow.get(candidate.window.id) !== candidate.cardId) continue;
|
|
229
|
+
const group = groups.get(candidate.cardId) ?? [];
|
|
230
|
+
group.push(candidate);
|
|
231
|
+
groups.set(candidate.cardId, group);
|
|
185
232
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
233
|
+
|
|
234
|
+
const projectedSessions = [];
|
|
235
|
+
for (const [cardId, group] of groups) {
|
|
236
|
+
const windowIds = new Set(group.map((candidate) => candidate.window.id));
|
|
237
|
+
if (windowIds.size !== 1) continue;
|
|
238
|
+
const [card] = projectSessionCards(group.map((candidate) => candidate.session));
|
|
239
|
+
if (!card) continue;
|
|
240
|
+
const window = group[0]?.window;
|
|
241
|
+
projectedSessions.push({ ...card, cardId, windowId: window?.id });
|
|
242
|
+
}
|
|
243
|
+
const focusedCard = projectedSessions.find((session) =>
|
|
244
|
+
windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
|
|
245
|
+
);
|
|
189
246
|
return {
|
|
190
247
|
workspace,
|
|
191
248
|
sessions: projectedSessions,
|
|
192
|
-
focusedSessionId:
|
|
249
|
+
focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
|
|
250
|
+
focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
|
|
193
251
|
};
|
|
194
252
|
}
|
|
195
253
|
|
|
@@ -216,65 +274,6 @@ export function resolveFocusedNiriWorkspace(workspaces) {
|
|
|
216
274
|
return matches.length === 1 ? matches[0] : null;
|
|
217
275
|
}
|
|
218
276
|
|
|
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
277
|
/**
|
|
279
278
|
* @param {string | Record<string, unknown>} session
|
|
280
279
|
* @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
|
|
@@ -304,7 +303,8 @@ export async function focusNiriSession(session, execFileAsync, env = process.env
|
|
|
304
303
|
if (!Array.isArray(windows)) {
|
|
305
304
|
return { ok: false, error: "Unexpected Niri window data; focus did nothing." };
|
|
306
305
|
}
|
|
307
|
-
const
|
|
306
|
+
const record = session && typeof session === "object" ? session : {};
|
|
307
|
+
const target = resolveExactGhosttyWindow(windows, sessionId, record);
|
|
308
308
|
if (!target) {
|
|
309
309
|
return {
|
|
310
310
|
ok: false,
|