@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.
@@ -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 ?? {})[key];
49
+ const value = /** @type {any} */ (toolInput)?.[key];
50
50
  if (typeof value === "string" && value.trim()) return preview(value);
51
51
  }
52
52
  return "";
@@ -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;
@@ -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 { projectSessionCards, sessionCardId } from "./session-cards.mjs";
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",
@@ -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(binary, [], {
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);