@tryinget/pi-activity-strip 0.5.0 → 0.7.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 +59 -7
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +98 -64
- 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 +20 -20
- package/native/panel/src/card_view.rs +396 -9
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +142 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +147 -60
- package/package.json +4 -4
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +13 -0
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +35 -0
- package/src/common/contracts.ts +67 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -111
- package/src/common/session-cards.mjs +5 -2
- package/src/common/status-report.mjs +51 -0
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/terminal-identity.mjs +75 -12
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +122 -64
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +70 -5
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- package/src/native/workspace-events.mjs +22 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "restores tiled windows stranded at the wrong height when the ribbon's exclusive zone appears or disappears"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing height repair, its evidence rules, or its safety bounds"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
export const HEIGHT_REPAIR_SETTLE_MS = 300;
|
|
8
|
+
export const HEIGHT_REPAIR_MAX_PER_TRANSITION = 48;
|
|
9
|
+
|
|
10
|
+
/** @typedef {{id?: unknown; is_floating?: unknown; layout?: {window_size?: number[]}}} NiriWindow */
|
|
11
|
+
/** @typedef {{id: number; height: number}} StaleWindow */
|
|
12
|
+
|
|
13
|
+
/** @param {NiriWindow} window */
|
|
14
|
+
function tiledHeight(window) {
|
|
15
|
+
if (window?.is_floating === true) return 0;
|
|
16
|
+
const height = Number(window?.layout?.window_size?.[1] ?? 0);
|
|
17
|
+
return Number.isFinite(height) && height > 0 ? height : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {NiriWindow[]} windows */
|
|
21
|
+
function heightsById(windows) {
|
|
22
|
+
/** @type {Map<number, number>} */
|
|
23
|
+
const heights = new Map();
|
|
24
|
+
for (const window of windows ?? []) {
|
|
25
|
+
if (!Number.isInteger(window?.id)) continue;
|
|
26
|
+
const height = tiledHeight(window);
|
|
27
|
+
if (height > 0) heights.set(Number(window.id), height);
|
|
28
|
+
}
|
|
29
|
+
return heights;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decide which windows the compositor left behind. Changing the exclusive zone changes the output
|
|
34
|
+
* working area, so every window whose height is automatic moves. A window that did not move while
|
|
35
|
+
* others did is only treated as stale when it still sits at a height those movers just vacated:
|
|
36
|
+
* that is positive evidence of the old working area, so a deliberately chosen height of any other
|
|
37
|
+
* value is never touched.
|
|
38
|
+
* @param {NiriWindow[]} before
|
|
39
|
+
* @param {NiriWindow[]} after
|
|
40
|
+
* @returns {StaleWindow[]}
|
|
41
|
+
*/
|
|
42
|
+
export function findStaleWindows(before, after) {
|
|
43
|
+
const previous = heightsById(before);
|
|
44
|
+
const current = heightsById(after);
|
|
45
|
+
/** @type {Set<number>} */
|
|
46
|
+
const vacated = new Set();
|
|
47
|
+
for (const [id, height] of current) {
|
|
48
|
+
const wasHeight = previous.get(id);
|
|
49
|
+
if (wasHeight !== undefined && wasHeight !== height) vacated.add(wasHeight);
|
|
50
|
+
}
|
|
51
|
+
if (vacated.size === 0) return [];
|
|
52
|
+
|
|
53
|
+
/** @type {StaleWindow[]} */
|
|
54
|
+
const stale = [];
|
|
55
|
+
for (const [id, height] of current) {
|
|
56
|
+
const wasHeight = previous.get(id);
|
|
57
|
+
if (wasHeight === undefined || wasHeight !== height) continue;
|
|
58
|
+
if (vacated.has(height)) stale.push({ id, height });
|
|
59
|
+
}
|
|
60
|
+
return stale.sort((left, right) => left.id - right.id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {{
|
|
65
|
+
* execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
|
|
66
|
+
* env?: NodeJS.ProcessEnv;
|
|
67
|
+
* runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
|
|
68
|
+
* readWindows: () => Promise<NiriWindow[]>;
|
|
69
|
+
* sleep?: (milliseconds: number) => Promise<void>;
|
|
70
|
+
* settleMs?: number;
|
|
71
|
+
* }} options
|
|
72
|
+
*/
|
|
73
|
+
export function createHeightRepair({
|
|
74
|
+
execFileAsync,
|
|
75
|
+
env = process.env,
|
|
76
|
+
runtimeStatus,
|
|
77
|
+
readWindows,
|
|
78
|
+
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
79
|
+
settleMs = HEIGHT_REPAIR_SETTLE_MS,
|
|
80
|
+
}) {
|
|
81
|
+
const enabled = env.PI_ACTIVITY_STRIP_HEIGHT_REPAIR !== "0";
|
|
82
|
+
/** @type {NiriWindow[]} */
|
|
83
|
+
let lastWindows = [];
|
|
84
|
+
/** Windows already reset at a given height, so one pass can never loop on one window. */
|
|
85
|
+
/** @type {Set<string>} */
|
|
86
|
+
const repaired = new Set();
|
|
87
|
+
let inFlight = false;
|
|
88
|
+
runtimeStatus.heightRepairState = enabled ? "enabled" : "disabled";
|
|
89
|
+
runtimeStatus.heightRepairCount = 0;
|
|
90
|
+
runtimeStatus.lastHeightRepairAt = 0;
|
|
91
|
+
|
|
92
|
+
/** @param {NiriWindow[]} windows */
|
|
93
|
+
function observe(windows) {
|
|
94
|
+
if (!Array.isArray(windows) || windows.length === 0) return;
|
|
95
|
+
// A window seen at a height other than the one it was reset at has moved since, so a future
|
|
96
|
+
// stranding there is a new event and must not be suppressed by the earlier repair.
|
|
97
|
+
for (const window of windows) {
|
|
98
|
+
if (!Number.isInteger(window?.id)) continue;
|
|
99
|
+
const height = tiledHeight(window);
|
|
100
|
+
if (height <= 0) continue;
|
|
101
|
+
for (const key of repaired) {
|
|
102
|
+
const [id, at] = key.split(":");
|
|
103
|
+
if (Number(id) === Number(window.id) && Number(at) !== height) repaired.delete(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
lastWindows = windows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Run after the panel reports that it applied a visibility change, which is the only moment the
|
|
111
|
+
* exclusive zone changes. One pass at a time; a pass never retries a window it already reset.
|
|
112
|
+
*/
|
|
113
|
+
async function repairAfterZoneChange() {
|
|
114
|
+
if (!enabled || inFlight) return [];
|
|
115
|
+
const before = lastWindows;
|
|
116
|
+
if (before.length === 0) return [];
|
|
117
|
+
inFlight = true;
|
|
118
|
+
try {
|
|
119
|
+
await sleep(settleMs);
|
|
120
|
+
const after = await readWindows();
|
|
121
|
+
if (after.length === 0) return [];
|
|
122
|
+
lastWindows = after;
|
|
123
|
+
const stale = findStaleWindows(before, after).filter(
|
|
124
|
+
(window) => !repaired.has(`${window.id}:${window.height}`),
|
|
125
|
+
);
|
|
126
|
+
const bounded = stale.slice(0, HEIGHT_REPAIR_MAX_PER_TRANSITION);
|
|
127
|
+
/** @type {number[]} */
|
|
128
|
+
const applied = [];
|
|
129
|
+
for (const window of bounded) {
|
|
130
|
+
try {
|
|
131
|
+
await execFileAsync(
|
|
132
|
+
"niri",
|
|
133
|
+
["msg", "action", "reset-window-height", "--id", String(window.id)],
|
|
134
|
+
{ env },
|
|
135
|
+
);
|
|
136
|
+
repaired.add(`${window.id}:${window.height}`);
|
|
137
|
+
applied.push(window.id);
|
|
138
|
+
} catch {
|
|
139
|
+
// A window that closed mid-pass, or a rejected action, is left to the next transition.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (applied.length > 0) {
|
|
143
|
+
runtimeStatus.heightRepairCount =
|
|
144
|
+
Number(runtimeStatus.heightRepairCount ?? 0) + applied.length;
|
|
145
|
+
runtimeStatus.lastHeightRepairAt = Date.now();
|
|
146
|
+
}
|
|
147
|
+
return applied;
|
|
148
|
+
} finally {
|
|
149
|
+
inFlight = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { observe, repairAfterZoneChange, enabled };
|
|
154
|
+
}
|
package/src/native/main.mjs
CHANGED
|
@@ -6,30 +6,27 @@
|
|
|
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";
|
|
12
|
+
import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
|
|
16
13
|
import { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
|
|
17
|
-
import {
|
|
18
|
-
focusNiriSession,
|
|
19
|
-
readNiriWindows,
|
|
20
|
-
readNiriWorkspaces,
|
|
21
|
-
resolveFocusedWorkspaceView,
|
|
22
|
-
} from "../common/niri-focus.mjs";
|
|
14
|
+
import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
|
|
23
15
|
import { haveSameRecordMembership } from "../common/session-cards.mjs";
|
|
16
|
+
import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
|
|
17
|
+
import { discoverAgentTabs } from "./agent-discovery.mjs";
|
|
18
|
+
import { startAkTaskProjection } from "./ak-runtime.mjs";
|
|
19
|
+
import { createHeightRepair } from "./height-repair.mjs";
|
|
20
|
+
import { resolvePanelBinary } from "./panel-binary.mjs";
|
|
24
21
|
import { createNativePanelProjection } from "./panel-projection.mjs";
|
|
22
|
+
import { createPlacementRuntime } from "./placement.mjs";
|
|
23
|
+
import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
|
|
25
24
|
import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
|
|
26
25
|
|
|
27
26
|
/** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
|
|
28
27
|
/** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
|
|
29
28
|
/** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
|
|
30
29
|
|
|
31
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
-
const packageRoot = path.resolve(__dirname, "..", "..");
|
|
33
30
|
const execFileAsync = promisify(execFile);
|
|
34
31
|
|
|
35
32
|
/** @type {import("../broker/server.mjs").ActivityStripBroker | null} */
|
|
@@ -66,6 +63,15 @@ const runtimeStatus = {
|
|
|
66
63
|
lastMovedCardId: null,
|
|
67
64
|
rendererCardCount: 0,
|
|
68
65
|
rendererCardIds: [],
|
|
66
|
+
rendererHiddenTabCardCount: 0,
|
|
67
|
+
agentTabCount: 0,
|
|
68
|
+
surfaceBindingCount: 0,
|
|
69
|
+
unplacedSurfaceCount: 0,
|
|
70
|
+
tabInventoryState: process.env.PI_ACTIVITY_STRIP_TAB_INVENTORY === "0" ? "disabled" : "pending",
|
|
71
|
+
tabInventoryDetail: null,
|
|
72
|
+
tabInventoryFrameCount: 0,
|
|
73
|
+
tabInventoryTabCount: 0,
|
|
74
|
+
tabInventoryProbedAt: null,
|
|
69
75
|
warnings: [],
|
|
70
76
|
error: null,
|
|
71
77
|
};
|
|
@@ -74,48 +80,14 @@ function isNiriSession() {
|
|
|
74
80
|
return Boolean(process.env.NIRI_SOCKET);
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
: []),
|
|
86
|
-
].filter(Boolean);
|
|
87
|
-
const binary = candidates.find((candidate) => {
|
|
88
|
-
try {
|
|
89
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
90
|
-
return true;
|
|
91
|
-
} catch {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
if (!binary) {
|
|
96
|
-
throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
|
|
97
|
-
}
|
|
98
|
-
if (allowUnverified) return binary;
|
|
99
|
-
if (process.platform !== "linux" || process.arch !== "x64") {
|
|
100
|
-
throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
|
|
101
|
-
}
|
|
102
|
-
const artifactPath = path.join(path.dirname(binary), "artifact.json");
|
|
103
|
-
let artifact;
|
|
104
|
-
try {
|
|
105
|
-
artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
|
|
106
|
-
} catch {
|
|
107
|
-
throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
|
|
108
|
-
}
|
|
109
|
-
const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
|
|
110
|
-
if (
|
|
111
|
-
artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
|
|
112
|
-
artifact.target !== "x86_64-unknown-linux-gnu" ||
|
|
113
|
-
artifact.sha256 !== digest
|
|
114
|
-
) {
|
|
115
|
-
throw new Error("Native panel binary does not match its reviewed artifact receipt.");
|
|
116
|
-
}
|
|
117
|
-
return binary;
|
|
118
|
-
}
|
|
83
|
+
// Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
|
|
84
|
+
// memory learned from visible titles and the bounded AT-SPI tab inventory.
|
|
85
|
+
const placement = createPlacementRuntime({
|
|
86
|
+
env: process.env,
|
|
87
|
+
runtimeStatus,
|
|
88
|
+
execFileAsync,
|
|
89
|
+
onBindingsLearned: () => reconcileRunner.request(),
|
|
90
|
+
});
|
|
119
91
|
|
|
120
92
|
/** @param {PanelMessage} message */
|
|
121
93
|
function writePanel(message) {
|
|
@@ -150,19 +122,32 @@ const projection = createNativePanelProjection({
|
|
|
150
122
|
publish(view) {
|
|
151
123
|
runtimeStatus.rendererCardCount = view.sessions.length;
|
|
152
124
|
runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
|
|
125
|
+
runtimeStatus.rendererHiddenTabCardCount = view.sessions.filter(
|
|
126
|
+
(session) => session.surfaceVisible === false,
|
|
127
|
+
).length;
|
|
128
|
+
runtimeStatus.akTaskRenderedCount = view.sessions.reduce(
|
|
129
|
+
(total, session) => total + (Array.isArray(session.akTasks) ? session.akTasks.length : 0),
|
|
130
|
+
0,
|
|
131
|
+
);
|
|
153
132
|
writePanel(view);
|
|
154
133
|
},
|
|
155
134
|
});
|
|
156
135
|
|
|
157
|
-
|
|
136
|
+
// AK task references are a read-only projection of claims held by live sessions; the strip
|
|
137
|
+
// never writes AK state and renders nothing when the read fails.
|
|
138
|
+
startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
|
|
139
|
+
|
|
140
|
+
/** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
|
|
158
141
|
async function focusSession(targetId) {
|
|
159
142
|
const session = projection.resolveTarget(targetId);
|
|
160
143
|
if (!session) {
|
|
161
144
|
return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
|
|
162
145
|
}
|
|
163
|
-
const rawResult = await focusNiriSession(session, execFileAsync, process.env);
|
|
146
|
+
const rawResult = await focusNiriSession(session, execFileAsync, process.env, placement.options);
|
|
164
147
|
const result = {
|
|
165
148
|
ok: rawResult.ok === true,
|
|
149
|
+
presented: rawResult.presented === true,
|
|
150
|
+
verified: rawResult.verified === true,
|
|
166
151
|
...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
|
|
167
152
|
...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
|
|
168
153
|
};
|
|
@@ -212,6 +197,7 @@ function consumePanelEvents(child, chunk) {
|
|
|
212
197
|
}
|
|
213
198
|
}, 30_000);
|
|
214
199
|
child._stableTimer.unref?.();
|
|
200
|
+
theme.republish();
|
|
215
201
|
if (pendingView) {
|
|
216
202
|
const view = pendingView;
|
|
217
203
|
pendingView = null;
|
|
@@ -220,7 +206,12 @@ function consumePanelEvents(child, chunk) {
|
|
|
220
206
|
projection.send();
|
|
221
207
|
}
|
|
222
208
|
} else if (event.type === "visibility-applied") {
|
|
223
|
-
|
|
209
|
+
const visible = event.visible === true;
|
|
210
|
+
const changed = runtimeStatus.windowVisible !== visible;
|
|
211
|
+
runtimeStatus.windowVisible = visible;
|
|
212
|
+
// Showing or hiding the surface is the only moment the exclusive zone changes, so it is
|
|
213
|
+
// the only moment a tiled window can be left at the previous working area's height.
|
|
214
|
+
if (changed) void heightRepair.repairAfterZoneChange();
|
|
224
215
|
} else if (event.type === "expanded") {
|
|
225
216
|
runtimeStatus.panelExpanded = event.expanded === true;
|
|
226
217
|
} else if (event.type === "activate") {
|
|
@@ -232,7 +223,11 @@ function consumePanelEvents(child, chunk) {
|
|
|
232
223
|
cardId,
|
|
233
224
|
ok: result.ok === true,
|
|
234
225
|
message: result.ok
|
|
235
|
-
?
|
|
226
|
+
? result.presented
|
|
227
|
+
? result.verified
|
|
228
|
+
? "Presented the hidden tab and focused its Ghostty window."
|
|
229
|
+
: "Focused its Ghostty window and asked Ghostty to show the tab."
|
|
230
|
+
: "Focused Ghostty window."
|
|
236
231
|
: result.error || "Focus failed; nothing moved.",
|
|
237
232
|
}),
|
|
238
233
|
);
|
|
@@ -254,9 +249,8 @@ function consumePanelEvents(child, chunk) {
|
|
|
254
249
|
|
|
255
250
|
function startPanel() {
|
|
256
251
|
if (shuttingDown) return;
|
|
257
|
-
const binary = panelBinaryPath();
|
|
258
252
|
/** @type {PanelChild} */
|
|
259
|
-
const child = spawn(
|
|
253
|
+
const child = spawn(resolvePanelBinary(process.env), [], {
|
|
260
254
|
env: process.env,
|
|
261
255
|
stdio: ["pipe", "pipe", "pipe"],
|
|
262
256
|
});
|
|
@@ -310,6 +304,49 @@ const getNiriWindows = () =>
|
|
|
310
304
|
const getNiriWorkspaces = () =>
|
|
311
305
|
readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
|
|
312
306
|
|
|
307
|
+
// Agent tabs are read from the process table, not the broker, so they are refreshed on a calm
|
|
308
|
+
// clock of their own rather than on every workspace event.
|
|
309
|
+
const AGENT_SCAN_INTERVAL_MS = 3000;
|
|
310
|
+
let agentScanAt = 0;
|
|
311
|
+
|
|
312
|
+
function refreshAgentTabs() {
|
|
313
|
+
if (process.env.PI_ACTIVITY_STRIP_AGENT_TABS === "0") return;
|
|
314
|
+
if (Date.now() - agentScanAt < AGENT_SCAN_INTERVAL_MS) return;
|
|
315
|
+
agentScanAt = Date.now();
|
|
316
|
+
try {
|
|
317
|
+
const records = discoverAgentTabs({ env: process.env });
|
|
318
|
+
runtimeStatus.agentTabCount = records.length;
|
|
319
|
+
runtimeStatus.agentScanError = null;
|
|
320
|
+
projection.setAgentSessions(records);
|
|
321
|
+
// A session that dies without firing its end hook leaves a record behind; retire the ones no
|
|
322
|
+
// live tab claims so they cannot accumulate.
|
|
323
|
+
pruneClaudeEventRecords(
|
|
324
|
+
records
|
|
325
|
+
.filter((record) => record.agentKind === "claude" && record.agentSessionKey)
|
|
326
|
+
.map((record) => String(record.agentSessionKey)),
|
|
327
|
+
);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
// Recorded on its own field: replacing the shared warning list would erase a panel error, and
|
|
330
|
+
// a transient scan failure must not leave a permanent warning behind.
|
|
331
|
+
runtimeStatus.agentScanError = error instanceof Error ? error.message : String(error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The ribbon reads Ghostty's own theme, so it follows the terminal into light and dark.
|
|
336
|
+
const theme = createThemeRuntime({
|
|
337
|
+
execFileAsync,
|
|
338
|
+
env: process.env,
|
|
339
|
+
runtimeStatus,
|
|
340
|
+
publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const heightRepair = createHeightRepair({
|
|
344
|
+
execFileAsync,
|
|
345
|
+
env: process.env,
|
|
346
|
+
runtimeStatus,
|
|
347
|
+
readWindows: getNiriWindows,
|
|
348
|
+
});
|
|
349
|
+
|
|
313
350
|
const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
|
|
314
351
|
if (!isNiriSession()) {
|
|
315
352
|
projection.send();
|
|
@@ -317,18 +354,25 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
|
|
|
317
354
|
}
|
|
318
355
|
const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
|
|
319
356
|
if (!isCurrent()) return;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
357
|
+
placement.observeWindowList(windows);
|
|
358
|
+
heightRepair.observe(windows);
|
|
359
|
+
refreshAgentTabs();
|
|
360
|
+
runtimeStatus.surfaceBindingCount = placement.bindingCount;
|
|
361
|
+
const sessions = projection.getRawSessions();
|
|
362
|
+
runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
|
|
363
|
+
const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
|
|
364
|
+
if (view) placement.learnFromPlacements(view.sessions, windows);
|
|
323
365
|
projection.publishWorkspaceView(
|
|
324
366
|
view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
|
|
325
367
|
);
|
|
368
|
+
void placement.scheduleInventory(windows);
|
|
326
369
|
});
|
|
327
370
|
|
|
328
371
|
async function shutdown(exitCode = 0) {
|
|
329
372
|
if (shuttingDown) return;
|
|
330
373
|
shuttingDown = true;
|
|
331
374
|
watcher?.stop();
|
|
375
|
+
placement.persist();
|
|
332
376
|
writePanel({
|
|
333
377
|
protocol: 1,
|
|
334
378
|
type: "view",
|
|
@@ -356,8 +400,10 @@ async function main() {
|
|
|
356
400
|
}),
|
|
357
401
|
});
|
|
358
402
|
broker.on("snapshot", (snapshot) => {
|
|
403
|
+
// Compare like with like: the raw set also holds discovered agent tabs, which the broker
|
|
404
|
+
// never publishes, so comparing it to a broker snapshot would always differ and reconcile.
|
|
359
405
|
const membershipChanged = !haveSameRecordMembership(
|
|
360
|
-
projection.
|
|
406
|
+
projection.getBrokerSessions(),
|
|
361
407
|
snapshot.sessions,
|
|
362
408
|
);
|
|
363
409
|
projection.updateSnapshot(snapshot);
|
|
@@ -373,10 +419,22 @@ async function main() {
|
|
|
373
419
|
env: process.env,
|
|
374
420
|
onFocusedWorkspace: () => reconcileRunner.request(),
|
|
375
421
|
onFallback: () => reconcileRunner.request(),
|
|
422
|
+
onWindowChanged: (window) => {
|
|
423
|
+
if (placement.observeWindowEvent(window)) reconcileRunner.request();
|
|
424
|
+
},
|
|
425
|
+
onWindowClosed: (windowId) => {
|
|
426
|
+
if (placement.forgetWindow(windowId)) reconcileRunner.request();
|
|
427
|
+
},
|
|
428
|
+
onWindowFocusChanged: () => reconcileRunner.request(),
|
|
429
|
+
onWorkspacesChanged: () => reconcileRunner.request(),
|
|
376
430
|
fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
|
|
377
431
|
});
|
|
378
432
|
}
|
|
379
433
|
|
|
434
|
+
void theme.refresh({ force: true });
|
|
435
|
+
const themeTimer = setInterval(() => void theme.refresh(), THEME_POLL_INTERVAL_MS);
|
|
436
|
+
themeTimer.unref?.();
|
|
437
|
+
|
|
380
438
|
process.once("SIGINT", () => void shutdown(0));
|
|
381
439
|
process.once("SIGTERM", () => void shutdown(0));
|
|
382
440
|
}
|
|
@@ -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
|
+
}
|
|
@@ -4,18 +4,23 @@
|
|
|
4
4
|
// - "changing native panel card membership, focus, or transport revisions"
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
|
+
import { joinAkTaskChips } from "../common/ak-tasks.mjs";
|
|
7
8
|
import { resolveSnapshotSession } from "../common/niri-focus.mjs";
|
|
8
9
|
import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
|
|
9
10
|
|
|
10
11
|
/** @typedef {Record<string, unknown>} SessionRecord */
|
|
11
12
|
/** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
|
|
12
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; workspaceIdx: number | null}} CardPlacement */
|
|
13
15
|
/** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
|
|
14
16
|
|
|
15
17
|
/** @param {NativePanelProjectionOptions} options */
|
|
16
18
|
export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
17
19
|
/** @type {Snapshot} */
|
|
18
20
|
let snapshot = { generatedAt: Date.now(), sessions: [] };
|
|
21
|
+
/** Agent tabs discovered from the process table; they publish no telemetry of their own. */
|
|
22
|
+
/** @type {SessionRecord[]} */
|
|
23
|
+
let agentSessions = [];
|
|
19
24
|
/** @type {string | null} */
|
|
20
25
|
let focusedSessionId = null;
|
|
21
26
|
/** @type {string | null} */
|
|
@@ -26,13 +31,44 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
26
31
|
let workspaceCardIds = new Set();
|
|
27
32
|
/** @type {Set<string>} */
|
|
28
33
|
let workspaceRecordKeys = new Set();
|
|
34
|
+
/** @type {Map<string, CardPlacement>} */
|
|
35
|
+
let workspacePlacements = new Map();
|
|
36
|
+
/** Read-only AK task claims joined onto cards; absent until the first successful AK read. */
|
|
37
|
+
/** @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
|
|
38
|
+
let akTasks = null;
|
|
29
39
|
let revision = 0;
|
|
30
40
|
|
|
41
|
+
function allSessions() {
|
|
42
|
+
return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
function getDisplaySessions() {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
const rawSessions = allSessions();
|
|
47
|
+
let cards;
|
|
48
|
+
if (!isNiriSession()) {
|
|
49
|
+
cards = projectSessionCards(rawSessions, null);
|
|
50
|
+
} else {
|
|
51
|
+
const sessions = rawSessions.filter((session) =>
|
|
52
|
+
workspaceRecordKeys.has(sessionRecordKey(session)),
|
|
53
|
+
);
|
|
54
|
+
cards = projectSessionCards(sessions, workspaceCardIds).map((card) => {
|
|
55
|
+
const placement = workspacePlacements.get(card.cardId);
|
|
56
|
+
return placement ? { ...card, ...placement } : card;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (!akTasks) return cards;
|
|
60
|
+
// Dead-session detection must consider every live session, including cards projected onto
|
|
61
|
+
// other workspaces, so a claim is only orphaned when no live session anywhere holds it.
|
|
62
|
+
const liveSessionIds = new Set(
|
|
63
|
+
rawSessions.map((session) => String(session.sessionId ?? "")).filter(Boolean),
|
|
64
|
+
);
|
|
65
|
+
return joinAkTaskChips({
|
|
66
|
+
cards,
|
|
67
|
+
claims: akTasks.claims,
|
|
68
|
+
deferred: akTasks.deferred,
|
|
69
|
+
liveSessionIds,
|
|
70
|
+
nowMs: Date.now(),
|
|
71
|
+
}).cards;
|
|
36
72
|
}
|
|
37
73
|
|
|
38
74
|
function currentView() {
|
|
@@ -64,7 +100,23 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
64
100
|
|
|
65
101
|
return {
|
|
66
102
|
getDisplaySessions,
|
|
67
|
-
getRawSessions:
|
|
103
|
+
getRawSessions: allSessions,
|
|
104
|
+
/** Broker-published records only, for comparisons against a broker snapshot. */
|
|
105
|
+
getBrokerSessions: () => snapshot.sessions,
|
|
106
|
+
/** @param {SessionRecord[]} records */
|
|
107
|
+
setAgentSessions(records) {
|
|
108
|
+
agentSessions = Array.isArray(records) ? records : [];
|
|
109
|
+
},
|
|
110
|
+
/**
|
|
111
|
+
* Replace the read-only AK task data joined onto cards. An empty refresh clears every chip;
|
|
112
|
+
* `null` data (AK never read) leaves cards untouched, which is how the strip stays unchanged
|
|
113
|
+
* when AK is absent.
|
|
114
|
+
* @param {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} state
|
|
115
|
+
*/
|
|
116
|
+
setAkTasks(state) {
|
|
117
|
+
akTasks = state;
|
|
118
|
+
send();
|
|
119
|
+
},
|
|
68
120
|
/** @param {string} targetId */
|
|
69
121
|
resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
|
|
70
122
|
/** @param {WorkspaceView} view */
|
|
@@ -78,6 +130,19 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
78
130
|
: [sessionRecordKey(session)],
|
|
79
131
|
),
|
|
80
132
|
);
|
|
133
|
+
workspacePlacements = new Map(
|
|
134
|
+
view.sessions.map((session) => [
|
|
135
|
+
String(session.cardId ?? ""),
|
|
136
|
+
{
|
|
137
|
+
placement: String(session.placement ?? "title"),
|
|
138
|
+
surfaceVisible: session.surfaceVisible !== false,
|
|
139
|
+
windowId: Number.isInteger(session.windowId) ? Number(session.windowId) : null,
|
|
140
|
+
workspaceIdx: Number.isInteger(session.workspaceIdx)
|
|
141
|
+
? Number(session.workspaceIdx)
|
|
142
|
+
: null,
|
|
143
|
+
},
|
|
144
|
+
]),
|
|
145
|
+
);
|
|
81
146
|
focusedSessionId = view.focusedSessionId;
|
|
82
147
|
focusedCardId = view.focusedCardId ?? null;
|
|
83
148
|
send();
|