@tryinget/pi-activity-strip 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -6
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +22 -1
- 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 +308 -9
- package/native/panel/src/protocol.rs +63 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +134 -60
- package/package.json +4 -4
- 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/contracts.d.ts +26 -0
- package/src/common/contracts.ts +64 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -110
- 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 +120 -14
- package/src/native/panel-projection.mjs +67 -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 +16 -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
|
@@ -13,15 +13,17 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { promisify } from "node:util";
|
|
14
14
|
import { createActivityStripBroker } from "../broker/server.mjs";
|
|
15
15
|
import { createLatestOnlyRunner } from "../common/alignment-controller.mjs";
|
|
16
|
+
import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
|
|
16
17
|
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";
|
|
18
|
+
import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
|
|
23
19
|
import { haveSameRecordMembership } from "../common/session-cards.mjs";
|
|
20
|
+
import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
|
|
21
|
+
import { discoverAgentTabs } from "./agent-discovery.mjs";
|
|
22
|
+
import { startAkTaskProjection } from "./ak-runtime.mjs";
|
|
23
|
+
import { createHeightRepair } from "./height-repair.mjs";
|
|
24
24
|
import { createNativePanelProjection } from "./panel-projection.mjs";
|
|
25
|
+
import { createPlacementRuntime } from "./placement.mjs";
|
|
26
|
+
import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
|
|
25
27
|
import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
|
|
26
28
|
|
|
27
29
|
/** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
|
|
@@ -66,6 +68,15 @@ const runtimeStatus = {
|
|
|
66
68
|
lastMovedCardId: null,
|
|
67
69
|
rendererCardCount: 0,
|
|
68
70
|
rendererCardIds: [],
|
|
71
|
+
rendererHiddenTabCardCount: 0,
|
|
72
|
+
agentTabCount: 0,
|
|
73
|
+
surfaceBindingCount: 0,
|
|
74
|
+
unplacedSurfaceCount: 0,
|
|
75
|
+
tabInventoryState: process.env.PI_ACTIVITY_STRIP_TAB_INVENTORY === "0" ? "disabled" : "pending",
|
|
76
|
+
tabInventoryDetail: null,
|
|
77
|
+
tabInventoryFrameCount: 0,
|
|
78
|
+
tabInventoryTabCount: 0,
|
|
79
|
+
tabInventoryProbedAt: null,
|
|
69
80
|
warnings: [],
|
|
70
81
|
error: null,
|
|
71
82
|
};
|
|
@@ -74,6 +85,15 @@ function isNiriSession() {
|
|
|
74
85
|
return Boolean(process.env.NIRI_SOCKET);
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
// Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
|
|
89
|
+
// memory learned from visible titles and the bounded AT-SPI tab inventory.
|
|
90
|
+
const placement = createPlacementRuntime({
|
|
91
|
+
env: process.env,
|
|
92
|
+
runtimeStatus,
|
|
93
|
+
execFileAsync,
|
|
94
|
+
onBindingsLearned: () => reconcileRunner.request(),
|
|
95
|
+
});
|
|
96
|
+
|
|
77
97
|
function panelBinaryPath() {
|
|
78
98
|
const override = process.env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
|
|
79
99
|
const allowUnverified = process.env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
|
|
@@ -150,19 +170,32 @@ const projection = createNativePanelProjection({
|
|
|
150
170
|
publish(view) {
|
|
151
171
|
runtimeStatus.rendererCardCount = view.sessions.length;
|
|
152
172
|
runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
|
|
173
|
+
runtimeStatus.rendererHiddenTabCardCount = view.sessions.filter(
|
|
174
|
+
(session) => session.surfaceVisible === false,
|
|
175
|
+
).length;
|
|
176
|
+
runtimeStatus.akTaskRenderedCount = view.sessions.reduce(
|
|
177
|
+
(total, session) => total + (Array.isArray(session.akTasks) ? session.akTasks.length : 0),
|
|
178
|
+
0,
|
|
179
|
+
);
|
|
153
180
|
writePanel(view);
|
|
154
181
|
},
|
|
155
182
|
});
|
|
156
183
|
|
|
157
|
-
|
|
184
|
+
// AK task references are a read-only projection of claims held by live sessions; the strip
|
|
185
|
+
// never writes AK state and renders nothing when the read fails.
|
|
186
|
+
startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
|
|
187
|
+
|
|
188
|
+
/** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
|
|
158
189
|
async function focusSession(targetId) {
|
|
159
190
|
const session = projection.resolveTarget(targetId);
|
|
160
191
|
if (!session) {
|
|
161
192
|
return { ok: false, error: "Session is no longer present or is ambiguous; focus did nothing." };
|
|
162
193
|
}
|
|
163
|
-
const rawResult = await focusNiriSession(session, execFileAsync, process.env);
|
|
194
|
+
const rawResult = await focusNiriSession(session, execFileAsync, process.env, placement.options);
|
|
164
195
|
const result = {
|
|
165
196
|
ok: rawResult.ok === true,
|
|
197
|
+
presented: rawResult.presented === true,
|
|
198
|
+
verified: rawResult.verified === true,
|
|
166
199
|
...(typeof rawResult.error === "string" ? { error: rawResult.error } : {}),
|
|
167
200
|
...(Number.isInteger(rawResult.windowId) ? { windowId: Number(rawResult.windowId) } : {}),
|
|
168
201
|
};
|
|
@@ -212,6 +245,7 @@ function consumePanelEvents(child, chunk) {
|
|
|
212
245
|
}
|
|
213
246
|
}, 30_000);
|
|
214
247
|
child._stableTimer.unref?.();
|
|
248
|
+
theme.republish();
|
|
215
249
|
if (pendingView) {
|
|
216
250
|
const view = pendingView;
|
|
217
251
|
pendingView = null;
|
|
@@ -220,7 +254,12 @@ function consumePanelEvents(child, chunk) {
|
|
|
220
254
|
projection.send();
|
|
221
255
|
}
|
|
222
256
|
} else if (event.type === "visibility-applied") {
|
|
223
|
-
|
|
257
|
+
const visible = event.visible === true;
|
|
258
|
+
const changed = runtimeStatus.windowVisible !== visible;
|
|
259
|
+
runtimeStatus.windowVisible = visible;
|
|
260
|
+
// Showing or hiding the surface is the only moment the exclusive zone changes, so it is
|
|
261
|
+
// the only moment a tiled window can be left at the previous working area's height.
|
|
262
|
+
if (changed) void heightRepair.repairAfterZoneChange();
|
|
224
263
|
} else if (event.type === "expanded") {
|
|
225
264
|
runtimeStatus.panelExpanded = event.expanded === true;
|
|
226
265
|
} else if (event.type === "activate") {
|
|
@@ -232,7 +271,11 @@ function consumePanelEvents(child, chunk) {
|
|
|
232
271
|
cardId,
|
|
233
272
|
ok: result.ok === true,
|
|
234
273
|
message: result.ok
|
|
235
|
-
?
|
|
274
|
+
? result.presented
|
|
275
|
+
? result.verified
|
|
276
|
+
? "Presented the hidden tab and focused its Ghostty window."
|
|
277
|
+
: "Focused its Ghostty window and asked Ghostty to show the tab."
|
|
278
|
+
: "Focused Ghostty window."
|
|
236
279
|
: result.error || "Focus failed; nothing moved.",
|
|
237
280
|
}),
|
|
238
281
|
);
|
|
@@ -310,6 +353,49 @@ const getNiriWindows = () =>
|
|
|
310
353
|
const getNiriWorkspaces = () =>
|
|
311
354
|
readNiriWorkspaces(execFileAsync, process.env, ACTIVITY_STRIP_WORKSPACE_SYNC_MS);
|
|
312
355
|
|
|
356
|
+
// Agent tabs are read from the process table, not the broker, so they are refreshed on a calm
|
|
357
|
+
// clock of their own rather than on every workspace event.
|
|
358
|
+
const AGENT_SCAN_INTERVAL_MS = 3000;
|
|
359
|
+
let agentScanAt = 0;
|
|
360
|
+
|
|
361
|
+
function refreshAgentTabs() {
|
|
362
|
+
if (process.env.PI_ACTIVITY_STRIP_AGENT_TABS === "0") return;
|
|
363
|
+
if (Date.now() - agentScanAt < AGENT_SCAN_INTERVAL_MS) return;
|
|
364
|
+
agentScanAt = Date.now();
|
|
365
|
+
try {
|
|
366
|
+
const records = discoverAgentTabs({ env: process.env });
|
|
367
|
+
runtimeStatus.agentTabCount = records.length;
|
|
368
|
+
runtimeStatus.agentScanError = null;
|
|
369
|
+
projection.setAgentSessions(records);
|
|
370
|
+
// A session that dies without firing its end hook leaves a record behind; retire the ones no
|
|
371
|
+
// live tab claims so they cannot accumulate.
|
|
372
|
+
pruneClaudeEventRecords(
|
|
373
|
+
records
|
|
374
|
+
.filter((record) => record.agentKind === "claude" && record.agentSessionKey)
|
|
375
|
+
.map((record) => String(record.agentSessionKey)),
|
|
376
|
+
);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
// Recorded on its own field: replacing the shared warning list would erase a panel error, and
|
|
379
|
+
// a transient scan failure must not leave a permanent warning behind.
|
|
380
|
+
runtimeStatus.agentScanError = error instanceof Error ? error.message : String(error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// The ribbon reads Ghostty's own theme, so it follows the terminal into light and dark.
|
|
385
|
+
const theme = createThemeRuntime({
|
|
386
|
+
execFileAsync,
|
|
387
|
+
env: process.env,
|
|
388
|
+
runtimeStatus,
|
|
389
|
+
publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const heightRepair = createHeightRepair({
|
|
393
|
+
execFileAsync,
|
|
394
|
+
env: process.env,
|
|
395
|
+
runtimeStatus,
|
|
396
|
+
readWindows: getNiriWindows,
|
|
397
|
+
});
|
|
398
|
+
|
|
313
399
|
const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
|
|
314
400
|
if (!isNiriSession()) {
|
|
315
401
|
projection.send();
|
|
@@ -317,18 +403,25 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
|
|
|
317
403
|
}
|
|
318
404
|
const [windows, workspaces] = await Promise.all([getNiriWindows(), getNiriWorkspaces()]);
|
|
319
405
|
if (!isCurrent()) return;
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
406
|
+
placement.observeWindowList(windows);
|
|
407
|
+
heightRepair.observe(windows);
|
|
408
|
+
refreshAgentTabs();
|
|
409
|
+
runtimeStatus.surfaceBindingCount = placement.bindingCount;
|
|
410
|
+
const sessions = projection.getRawSessions();
|
|
411
|
+
runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
|
|
412
|
+
const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
|
|
413
|
+
if (view) placement.learnFromPlacements(view.sessions, windows);
|
|
323
414
|
projection.publishWorkspaceView(
|
|
324
415
|
view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
|
|
325
416
|
);
|
|
417
|
+
void placement.scheduleInventory(windows);
|
|
326
418
|
});
|
|
327
419
|
|
|
328
420
|
async function shutdown(exitCode = 0) {
|
|
329
421
|
if (shuttingDown) return;
|
|
330
422
|
shuttingDown = true;
|
|
331
423
|
watcher?.stop();
|
|
424
|
+
placement.persist();
|
|
332
425
|
writePanel({
|
|
333
426
|
protocol: 1,
|
|
334
427
|
type: "view",
|
|
@@ -356,8 +449,10 @@ async function main() {
|
|
|
356
449
|
}),
|
|
357
450
|
});
|
|
358
451
|
broker.on("snapshot", (snapshot) => {
|
|
452
|
+
// Compare like with like: the raw set also holds discovered agent tabs, which the broker
|
|
453
|
+
// never publishes, so comparing it to a broker snapshot would always differ and reconcile.
|
|
359
454
|
const membershipChanged = !haveSameRecordMembership(
|
|
360
|
-
projection.
|
|
455
|
+
projection.getBrokerSessions(),
|
|
361
456
|
snapshot.sessions,
|
|
362
457
|
);
|
|
363
458
|
projection.updateSnapshot(snapshot);
|
|
@@ -373,10 +468,21 @@ async function main() {
|
|
|
373
468
|
env: process.env,
|
|
374
469
|
onFocusedWorkspace: () => reconcileRunner.request(),
|
|
375
470
|
onFallback: () => reconcileRunner.request(),
|
|
471
|
+
onWindowChanged: (window) => {
|
|
472
|
+
if (placement.observeWindowEvent(window)) reconcileRunner.request();
|
|
473
|
+
},
|
|
474
|
+
onWindowClosed: (windowId) => {
|
|
475
|
+
if (placement.forgetWindow(windowId)) reconcileRunner.request();
|
|
476
|
+
},
|
|
477
|
+
onWindowFocusChanged: () => reconcileRunner.request(),
|
|
376
478
|
fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
|
|
377
479
|
});
|
|
378
480
|
}
|
|
379
481
|
|
|
482
|
+
void theme.refresh({ force: true });
|
|
483
|
+
const themeTimer = setInterval(() => void theme.refresh(), THEME_POLL_INTERVAL_MS);
|
|
484
|
+
themeTimer.unref?.();
|
|
485
|
+
|
|
380
486
|
process.once("SIGINT", () => void shutdown(0));
|
|
381
487
|
process.once("SIGTERM", () => void shutdown(0));
|
|
382
488
|
}
|
|
@@ -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}} 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,16 @@ 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
|
+
},
|
|
141
|
+
]),
|
|
142
|
+
);
|
|
81
143
|
focusedSessionId = view.focusedSessionId;
|
|
82
144
|
focusedCardId = view.focusedCardId ?? null;
|
|
83
145
|
send();
|