@tryinget/pi-activity-strip 0.6.0 → 0.8.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 +21 -3
- package/bin/pi-activity-strip.mjs +76 -63
- package/extensions/activity-strip.js +25 -21
- package/native/bin/linux-x64-gnu/artifact.json +2 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +141 -13
- package/native/panel/src/card_view.rs +149 -29
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +79 -0
- package/native/panel/src/style.css +22 -5
- package/package.json +1 -1
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/claude-events.mjs +1 -1
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +9 -0
- package/src/common/contracts.ts +3 -0
- package/src/common/niri-focus.mjs +1 -2
- package/src/common/workspace-view.mjs +5 -5
- package/src/native/main.mjs +3 -51
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +4 -1
- package/src/native/workspace-events.mjs +6 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "waits on the native runtime flock so stop returns only after the controller exits, and open can name a held lock"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing how stop waits for shutdown or how open reports a runtime that failed to start"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import {
|
|
12
|
+
ACTIVITY_STRIP_SOCKET_DIR,
|
|
13
|
+
ACTIVITY_STRIP_START_TIMEOUT_MS,
|
|
14
|
+
ACTIVITY_STRIP_STOP_TIMEOUT_MS,
|
|
15
|
+
} from "../common/constants.mjs";
|
|
16
|
+
import { requestBrokerShutdown } from "./broker-client.mjs";
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The exit status `flock` uses for a held lock. It differs from every status the controller or
|
|
22
|
+
* `flock` itself can return, so a held lock is never confused with a failure to check it.
|
|
23
|
+
*/
|
|
24
|
+
export const RUNTIME_LOCK_CONFLICT_EXIT_CODE = 75;
|
|
25
|
+
|
|
26
|
+
/** The controller holds this lock for its whole lifetime. */
|
|
27
|
+
export const RUNTIME_LOCK_PATH = path.join(ACTIVITY_STRIP_SOCKET_DIR, "runtime.lock");
|
|
28
|
+
|
|
29
|
+
/** @typedef {import("../common/contracts.ts").BrokerResponse} BrokerResponse */
|
|
30
|
+
/** @typedef {{exitCode: number | null | undefined}} ControllerHandle */
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Take and release the lock once, reporting whether it was held.
|
|
34
|
+
* @param {string} lockPath
|
|
35
|
+
* @param {string[]} mode
|
|
36
|
+
* @returns {Promise<"free" | "held" | {error: string}>}
|
|
37
|
+
*/
|
|
38
|
+
async function tryRuntimeLock(lockPath, mode) {
|
|
39
|
+
const conflict = String(RUNTIME_LOCK_CONFLICT_EXIT_CODE);
|
|
40
|
+
try {
|
|
41
|
+
await execFileAsync("flock", [...mode, "--conflict-exit-code", conflict, lockPath, "true"]);
|
|
42
|
+
return "free";
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const failure = /** @type {{code?: unknown; message?: string}} */ (error ?? {});
|
|
45
|
+
if (failure.code === RUNTIME_LOCK_CONFLICT_EXIT_CODE) return "held";
|
|
46
|
+
return {
|
|
47
|
+
error: `Could not check the activity-strip runtime lock: ${failure.message ?? String(error)}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wait until the controller holding the runtime lock has exited. The controller holds the lock for
|
|
54
|
+
* its whole lifetime and closes its broker before it lets go, so a closed broker alone does not
|
|
55
|
+
* prove it is gone. `waited` says whether a runtime was still there.
|
|
56
|
+
* @param {string} lockPath
|
|
57
|
+
* @param {{timeoutMs: number}} options
|
|
58
|
+
* @returns {Promise<{released: boolean; waited?: boolean; error?: string}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function waitForRuntimeExit(lockPath, { timeoutMs }) {
|
|
61
|
+
const now = await tryRuntimeLock(lockPath, ["--nonblock"]);
|
|
62
|
+
if (now === "free") return { released: true, waited: false };
|
|
63
|
+
if (now !== "held") return { released: false, error: now.error };
|
|
64
|
+
const later = await tryRuntimeLock(lockPath, ["--wait", String(timeoutMs / 1000)]);
|
|
65
|
+
if (later === "free") return { released: true, waited: true };
|
|
66
|
+
if (later === "held") return { released: false, waited: true };
|
|
67
|
+
return { released: false, error: later.error };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stop the runtime and return once it has exited. Every stop path, the CLI and Pi's commands, goes
|
|
72
|
+
* through here, so a following open can never race a runtime that is still shutting down. The lock
|
|
73
|
+
* is waited on even when no broker answers: a runtime whose broker has closed still holds it.
|
|
74
|
+
* @param {{
|
|
75
|
+
* requestShutdown?: () => Promise<{ok?: boolean} | null | undefined>;
|
|
76
|
+
* waitForExit?: typeof waitForRuntimeExit;
|
|
77
|
+
* lockPath?: string;
|
|
78
|
+
* timeoutMs?: number;
|
|
79
|
+
* }} [options]
|
|
80
|
+
* @returns {Promise<{outcome: "stopped" | "not-running" | "still-stopping"} | {outcome: "error"; error: string}>}
|
|
81
|
+
*/
|
|
82
|
+
export async function stopRuntime({
|
|
83
|
+
requestShutdown = requestBrokerShutdown,
|
|
84
|
+
waitForExit = waitForRuntimeExit,
|
|
85
|
+
lockPath = RUNTIME_LOCK_PATH,
|
|
86
|
+
timeoutMs = ACTIVITY_STRIP_STOP_TIMEOUT_MS,
|
|
87
|
+
} = {}) {
|
|
88
|
+
let accepted = false;
|
|
89
|
+
try {
|
|
90
|
+
accepted = (await requestShutdown())?.ok === true;
|
|
91
|
+
} catch {
|
|
92
|
+
accepted = false;
|
|
93
|
+
}
|
|
94
|
+
const exit = await waitForExit(lockPath, { timeoutMs });
|
|
95
|
+
if (exit.error) return { outcome: "error", error: exit.error };
|
|
96
|
+
if (!exit.released) return { outcome: "still-stopping" };
|
|
97
|
+
return { outcome: accepted || exit.waited ? "stopped" : "not-running" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wait for the controller `open` just spawned under the runtime lock to become ready. A spawn that
|
|
102
|
+
* finds the lock held exits with the conflict status. The wait still continues, because the holder
|
|
103
|
+
* may be a controller another `open` is starting; the held lock is reported only if nothing
|
|
104
|
+
* becomes ready in time.
|
|
105
|
+
* @param {{
|
|
106
|
+
* controller: ControllerHandle;
|
|
107
|
+
* getStatus: () => Promise<BrokerResponse | null>;
|
|
108
|
+
* timeoutMs?: number;
|
|
109
|
+
* retryMs?: number;
|
|
110
|
+
* now?: () => number;
|
|
111
|
+
* sleep?: (ms: number) => Promise<unknown>;
|
|
112
|
+
* }} options
|
|
113
|
+
* @returns {Promise<{ok: true; started: boolean} | {ok: false; reason: "error" | "exited" | "lock-held" | "timeout"; status: BrokerResponse | null; exitCode?: number | null}>}
|
|
114
|
+
*/
|
|
115
|
+
export async function waitForStartedRuntime({
|
|
116
|
+
controller,
|
|
117
|
+
getStatus,
|
|
118
|
+
timeoutMs = ACTIVITY_STRIP_START_TIMEOUT_MS,
|
|
119
|
+
retryMs = 125,
|
|
120
|
+
now = Date.now,
|
|
121
|
+
sleep = delay,
|
|
122
|
+
}) {
|
|
123
|
+
const deadline = now() + timeoutMs;
|
|
124
|
+
while (true) {
|
|
125
|
+
const status = await getStatus();
|
|
126
|
+
if (status?.ok && (!status.runtimeStatus || status.runtimeStatus.state === "ready")) {
|
|
127
|
+
return { ok: true, started: controller.exitCode === undefined };
|
|
128
|
+
}
|
|
129
|
+
if (status?.runtimeStatus?.state === "error") return { ok: false, reason: "error", status };
|
|
130
|
+
const exitCode = controller.exitCode;
|
|
131
|
+
if (exitCode !== undefined && exitCode !== RUNTIME_LOCK_CONFLICT_EXIT_CODE) {
|
|
132
|
+
return { ok: false, reason: "exited", status, exitCode };
|
|
133
|
+
}
|
|
134
|
+
if (now() >= deadline) {
|
|
135
|
+
const reason = exitCode === RUNTIME_LOCK_CONFLICT_EXIT_CODE ? "lock-held" : "timeout";
|
|
136
|
+
return { ok: false, reason, status };
|
|
137
|
+
}
|
|
138
|
+
await sleep(retryMs);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -46,7 +46,7 @@ export function claudeEventPath(sessionId, directory = CLAUDE_EVENT_DIR) {
|
|
|
46
46
|
*/
|
|
47
47
|
export function describeHookToolTarget(toolInput) {
|
|
48
48
|
for (const key of ["description", "command", "file_path", "path", "pattern", "query", "url"]) {
|
|
49
|
-
const value = /** @type {any} */ (toolInput
|
|
49
|
+
const value = /** @type {any} */ (toolInput)?.[key];
|
|
50
50
|
if (typeof value === "string" && value.trim()) return preview(value);
|
|
51
51
|
}
|
|
52
52
|
return "";
|
package/src/common/constants.mjs
CHANGED
|
@@ -25,5 +25,7 @@ export const ACTIVITY_STRIP_BROADCAST_TICK_MS = 1000;
|
|
|
25
25
|
export const ACTIVITY_STRIP_SEND_THROTTLE_MS = 120;
|
|
26
26
|
export const ACTIVITY_STRIP_CONNECT_TIMEOUT_MS = 450;
|
|
27
27
|
export const ACTIVITY_STRIP_START_TIMEOUT_MS = 5000;
|
|
28
|
+
/** How long `stop` waits for the runtime to exit; an in-flight read-only `ak` query can take 8 s. */
|
|
29
|
+
export const ACTIVITY_STRIP_STOP_TIMEOUT_MS = 15_000;
|
|
28
30
|
export const ACTIVITY_STRIP_EVENT_STALL_MS = 15 * 60_000;
|
|
29
31
|
export const ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS = [250, 750];
|
|
@@ -19,6 +19,15 @@ 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
|
+
/** Niri window id; for a hidden tab, the window hosting it. */
|
|
26
|
+
windowId?: number | null;
|
|
27
|
+
/** Niri workspace `idx`, the number the operator sees, never the internal workspace id. */
|
|
28
|
+
workspaceIdx?: number | null;
|
|
29
|
+
placement?: "title" | "host" | "binding";
|
|
30
|
+
surfaceVisible?: boolean;
|
|
22
31
|
cwd: string;
|
|
23
32
|
repoLabel: string;
|
|
24
33
|
sessionName: string;
|
package/src/common/contracts.ts
CHANGED
|
@@ -22,7 +22,10 @@ export interface SessionSnapshot {
|
|
|
22
22
|
publisherSessionIds?: string[];
|
|
23
23
|
akTasks?: AkTaskChip[];
|
|
24
24
|
akTaskOverflow?: number;
|
|
25
|
+
/** Niri window id; for a hidden tab, the window hosting it. */
|
|
25
26
|
windowId?: number | null;
|
|
27
|
+
/** Niri workspace `idx`, the number the operator sees, never the internal workspace id. */
|
|
28
|
+
workspaceIdx?: number | null;
|
|
26
29
|
placement?: "title" | "host" | "binding";
|
|
27
30
|
surfaceVisible?: boolean;
|
|
28
31
|
cwd: string;
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { presentGhosttySurface } from "./ghostty-present.mjs";
|
|
10
|
-
import {
|
|
11
|
-
import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
|
|
10
|
+
import { sessionCardId } from "./session-cards.mjs";
|
|
12
11
|
import {
|
|
13
12
|
appIdForGhosttyFamily,
|
|
14
13
|
canonicalGhosttyTerminalKey,
|
|
@@ -4,11 +4,7 @@
|
|
|
4
4
|
// - "changing workspace card membership, card visibility, or focused-card selection"
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
canVerifyTabVisibility,
|
|
9
|
-
placeSessionWindow,
|
|
10
|
-
resolvePiSessionIdentity,
|
|
11
|
-
} from "./niri-focus.mjs";
|
|
7
|
+
import { canVerifyTabVisibility, placeSessionWindow } from "./niri-focus.mjs";
|
|
12
8
|
import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
|
|
13
9
|
import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
|
|
14
10
|
import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
|
|
@@ -96,6 +92,8 @@ export function resolveWorkspaceView(windows, workspace, sessions, options = {})
|
|
|
96
92
|
groups.set(candidate.cardId, group);
|
|
97
93
|
}
|
|
98
94
|
|
|
95
|
+
// The number the operator sees in the workspace list, not Niri's internal workspace id.
|
|
96
|
+
const workspaceIdx = Number.isInteger(workspace.idx) ? Number(workspace.idx) : null;
|
|
99
97
|
const projectedSessions = [];
|
|
100
98
|
for (const [cardId, group] of groups) {
|
|
101
99
|
const windowIds = new Set(group.map((candidate) => candidate.window.id));
|
|
@@ -115,7 +113,9 @@ export function resolveWorkspaceView(windows, workspace, sessions, options = {})
|
|
|
115
113
|
projectedSessions.push({
|
|
116
114
|
...card,
|
|
117
115
|
cardId,
|
|
116
|
+
// A hidden tab has no window of its own, so this is the window hosting it.
|
|
118
117
|
windowId: window?.id,
|
|
118
|
+
workspaceIdx,
|
|
119
119
|
placement,
|
|
120
120
|
// Only a tab proven to sit behind another one is marked hidden.
|
|
121
121
|
surfaceVisible: visibility !== "hidden",
|
package/src/native/main.mjs
CHANGED
|
@@ -6,10 +6,6 @@
|
|
|
6
6
|
// ---
|
|
7
7
|
|
|
8
8
|
import { execFile, spawn } from "node:child_process";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
|
-
import fs from "node:fs";
|
|
11
|
-
import path from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
9
|
import { promisify } from "node:util";
|
|
14
10
|
import { createActivityStripBroker } from "../broker/server.mjs";
|
|
15
11
|
import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
|
|
@@ -21,6 +17,7 @@ import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
|
|
|
21
17
|
import { discoverAgentTabs } from "./agent-discovery.mjs";
|
|
22
18
|
import { startAkTaskProjection } from "./ak-runtime.mjs";
|
|
23
19
|
import { createHeightRepair } from "./height-repair.mjs";
|
|
20
|
+
import { resolvePanelBinary } from "./panel-binary.mjs";
|
|
24
21
|
import { createNativePanelProjection } from "./panel-projection.mjs";
|
|
25
22
|
import { createPlacementRuntime } from "./placement.mjs";
|
|
26
23
|
import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
|
|
@@ -30,8 +27,6 @@ import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
|
|
|
30
27
|
/** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
|
|
31
28
|
/** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
|
|
32
29
|
|
|
33
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
-
const packageRoot = path.resolve(__dirname, "..", "..");
|
|
35
30
|
const execFileAsync = promisify(execFile);
|
|
36
31
|
|
|
37
32
|
/** @type {import("../broker/server.mjs").ActivityStripBroker | null} */
|
|
@@ -94,49 +89,6 @@ const placement = createPlacementRuntime({
|
|
|
94
89
|
onBindingsLearned: () => reconcileRunner.request(),
|
|
95
90
|
});
|
|
96
91
|
|
|
97
|
-
function panelBinaryPath() {
|
|
98
|
-
const override = process.env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
|
|
99
|
-
const allowUnverified = process.env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
|
|
100
|
-
const candidates = [
|
|
101
|
-
...(override ? [override] : []),
|
|
102
|
-
path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
|
|
103
|
-
...(allowUnverified
|
|
104
|
-
? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
|
|
105
|
-
: []),
|
|
106
|
-
].filter(Boolean);
|
|
107
|
-
const binary = candidates.find((candidate) => {
|
|
108
|
-
try {
|
|
109
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
110
|
-
return true;
|
|
111
|
-
} catch {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
if (!binary) {
|
|
116
|
-
throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
|
|
117
|
-
}
|
|
118
|
-
if (allowUnverified) return binary;
|
|
119
|
-
if (process.platform !== "linux" || process.arch !== "x64") {
|
|
120
|
-
throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
|
|
121
|
-
}
|
|
122
|
-
const artifactPath = path.join(path.dirname(binary), "artifact.json");
|
|
123
|
-
let artifact;
|
|
124
|
-
try {
|
|
125
|
-
artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
|
|
126
|
-
} catch {
|
|
127
|
-
throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
|
|
128
|
-
}
|
|
129
|
-
const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
|
|
130
|
-
if (
|
|
131
|
-
artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
|
|
132
|
-
artifact.target !== "x86_64-unknown-linux-gnu" ||
|
|
133
|
-
artifact.sha256 !== digest
|
|
134
|
-
) {
|
|
135
|
-
throw new Error("Native panel binary does not match its reviewed artifact receipt.");
|
|
136
|
-
}
|
|
137
|
-
return binary;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
92
|
/** @param {PanelMessage} message */
|
|
141
93
|
function writePanel(message) {
|
|
142
94
|
if (!panelReady || !panelWriteReady || !panel?.stdin?.writable) {
|
|
@@ -297,9 +249,8 @@ function consumePanelEvents(child, chunk) {
|
|
|
297
249
|
|
|
298
250
|
function startPanel() {
|
|
299
251
|
if (shuttingDown) return;
|
|
300
|
-
const binary = panelBinaryPath();
|
|
301
252
|
/** @type {PanelChild} */
|
|
302
|
-
const child = spawn(
|
|
253
|
+
const child = spawn(resolvePanelBinary(process.env), [], {
|
|
303
254
|
env: process.env,
|
|
304
255
|
stdio: ["pipe", "pipe", "pipe"],
|
|
305
256
|
});
|
|
@@ -475,6 +426,7 @@ async function main() {
|
|
|
475
426
|
if (placement.forgetWindow(windowId)) reconcileRunner.request();
|
|
476
427
|
},
|
|
477
428
|
onWindowFocusChanged: () => reconcileRunner.request(),
|
|
429
|
+
onWorkspacesChanged: () => reconcileRunner.request(),
|
|
478
430
|
fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
|
|
479
431
|
});
|
|
480
432
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "locates the native panel binary and verifies it against its reviewed artifact receipt"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing which panel binary the controller starts or how its receipt is checked"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
13
|
+
|
|
14
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
15
|
+
export function resolvePanelBinary(env) {
|
|
16
|
+
const override = env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
|
|
17
|
+
const allowUnverified = env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
|
|
18
|
+
const candidates = [
|
|
19
|
+
...(override ? [override] : []),
|
|
20
|
+
path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
|
|
21
|
+
...(allowUnverified
|
|
22
|
+
? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
|
|
23
|
+
: []),
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
const binary = candidates.find((candidate) => {
|
|
26
|
+
try {
|
|
27
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
if (!binary) {
|
|
34
|
+
throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
|
|
35
|
+
}
|
|
36
|
+
if (allowUnverified) return binary;
|
|
37
|
+
if (process.platform !== "linux" || process.arch !== "x64") {
|
|
38
|
+
throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
|
|
39
|
+
}
|
|
40
|
+
const artifactPath = path.join(path.dirname(binary), "artifact.json");
|
|
41
|
+
let artifact;
|
|
42
|
+
try {
|
|
43
|
+
artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
|
|
46
|
+
}
|
|
47
|
+
const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
|
|
48
|
+
if (
|
|
49
|
+
artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
|
|
50
|
+
artifact.target !== "x86_64-unknown-linux-gnu" ||
|
|
51
|
+
artifact.sha256 !== digest
|
|
52
|
+
) {
|
|
53
|
+
throw new Error("Native panel binary does not match its reviewed artifact receipt.");
|
|
54
|
+
}
|
|
55
|
+
return binary;
|
|
56
|
+
}
|
|
@@ -11,7 +11,7 @@ import { projectSessionCards, sessionRecordKey } from "../common/session-cards.m
|
|
|
11
11
|
/** @typedef {Record<string, unknown>} SessionRecord */
|
|
12
12
|
/** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
|
|
13
13
|
/** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
|
|
14
|
-
/** @typedef {{placement: string; surfaceVisible: boolean; windowId: number | null}} CardPlacement */
|
|
14
|
+
/** @typedef {{placement: string; surfaceVisible: boolean; windowId: number | null; workspaceIdx: number | null}} CardPlacement */
|
|
15
15
|
/** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
|
|
16
16
|
|
|
17
17
|
/** @param {NativePanelProjectionOptions} options */
|
|
@@ -137,6 +137,9 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
137
137
|
placement: String(session.placement ?? "title"),
|
|
138
138
|
surfaceVisible: session.surfaceVisible !== false,
|
|
139
139
|
windowId: Number.isInteger(session.windowId) ? Number(session.windowId) : null,
|
|
140
|
+
workspaceIdx: Number.isInteger(session.workspaceIdx)
|
|
141
|
+
? Number(session.workspaceIdx)
|
|
142
|
+
: null,
|
|
140
143
|
},
|
|
141
144
|
]),
|
|
142
145
|
);
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* stream while retaining the slower poll as a fail-closed fallback. Window
|
|
10
10
|
* open/change/close and focus events are forwarded so title changes that reveal
|
|
11
11
|
* a Ghostty tab, or windows moving between workspaces, are seen between polls.
|
|
12
|
+
* Workspace list changes are forwarded too, because reordering or removing a
|
|
13
|
+
* workspace renumbers the focused one without activating anything.
|
|
12
14
|
* @param {{
|
|
13
15
|
* spawn: typeof import("node:child_process").spawn;
|
|
14
16
|
* env: NodeJS.ProcessEnv;
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
* onWindowChanged?: (window: Record<string, unknown>) => void;
|
|
18
20
|
* onWindowClosed?: (windowId: number) => void;
|
|
19
21
|
* onWindowFocusChanged?: (windowId: number | null) => void;
|
|
22
|
+
* onWorkspacesChanged?: () => void;
|
|
20
23
|
* fallbackMs: number;
|
|
21
24
|
* setIntervalFn?: typeof setInterval;
|
|
22
25
|
* clearIntervalFn?: typeof clearInterval;
|
|
@@ -57,6 +60,9 @@ export function createNiriWorkspaceEventWatcher(options) {
|
|
|
57
60
|
lastFocusedWorkspaceId = activation.id;
|
|
58
61
|
options.onFocusedWorkspace(activation.id);
|
|
59
62
|
}
|
|
63
|
+
if (event && typeof event === "object" && "WorkspacesChanged" in event) {
|
|
64
|
+
options.onWorkspacesChanged?.();
|
|
65
|
+
}
|
|
60
66
|
const changedWindow = event?.WindowOpenedOrChanged?.window;
|
|
61
67
|
if (changedWindow && typeof changedWindow === "object") {
|
|
62
68
|
options.onWindowChanged?.(changedWindow);
|