@tryinget/pi-activity-strip 0.6.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 +19 -2
- 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/card_view.rs +93 -5
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +79 -0
- package/native/panel/src/style.css +13 -0
- 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
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ The runtime is Electron-free. A Node controller retains the tested telemetry, id
|
|
|
28
28
|
- shows the AK task a session is working on: read-only `ak` output is joined onto cards, clicking the task reference focuses the claiming terminal, and claims that outlive their session or deferred tasks appear as non-clickable badges
|
|
29
29
|
- displays repo, phase, tool, detail, elapsed time, and freshness
|
|
30
30
|
- marks the exact currently focused terminal card and prefixes hidden-tab cards with `⧉`
|
|
31
|
+
- labels each card with its Niri window id (`#43`), so a window an agent or tool names by id can be found on the ribbon
|
|
31
32
|
- keeps monitoring-success cards beside the Activity tile, then active and settled cards
|
|
32
33
|
- expands rich details on hover or keyboard focus
|
|
33
34
|
- supports Left/Right navigation and Shift+Left/Right manual movement
|
|
@@ -46,9 +47,11 @@ Pi publisher streams
|
|
|
46
47
|
-> wlr-layer-shell top surface with an 84px exclusive zone
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
Every child-protocol message carries `protocol: 1`, and the panel drops any view carrying another version. A new card field is therefore added as an optional field: a panel that does not know it ignores it, and a panel talking to a controller that does not send it falls back to a default. The version changes only for a change an existing panel would misread.
|
|
51
|
+
|
|
49
52
|
Layer-shell replaces the old floating Electron window and dynamic Niri-config strut helper. The package no longer edits `~/.config/niri/config.kdl`, resets tiled heights, or requires Electron.
|
|
50
53
|
|
|
51
|
-
The compact surface is 84px tall and sits inset by an 8px margin on the top, left and right, matching the compositor's window gaps, with the same 12px corner radius as tiled windows. It therefore reserves 92px in total. One engaged card expands the surface to 276px while the reservation is unchanged, so detail overlays content without repeatedly resizing tiled windows.
|
|
54
|
+
The compact surface is 84px tall and sits inset by an 8px margin on the top, left and right, matching the compositor's window gaps, with the same 12px corner radius as tiled windows. It therefore reserves 92px in total. One engaged card expands the surface to at least 276px, taller when its detail rows wrap, while the reservation is unchanged, so detail overlays content without repeatedly resizing tiled windows.
|
|
52
55
|
|
|
53
56
|
## Supported host
|
|
54
57
|
|
|
@@ -100,6 +103,8 @@ node ./bin/pi-activity-strip.mjs claude-hooks
|
|
|
100
103
|
node ./bin/pi-activity-strip.mjs stop
|
|
101
104
|
```
|
|
102
105
|
|
|
106
|
+
`stop` returns once the runtime has exited and released its lock, waiting up to 15 seconds and exiting non-zero if it has not, so `npm run strip:stop && npm run strip:open` restarts cleanly. Pi's `/activity-strip stop` and `/activity-strip-stop` wait the same way. An `open` that finds the lock still held by a runtime that never answers says so instead of timing out silently.
|
|
107
|
+
|
|
103
108
|
`fix-top` is now a compatibility no-op: layer-shell placement is compositor-owned.
|
|
104
109
|
|
|
105
110
|
## Keyboard-only entry
|
|
@@ -116,11 +121,12 @@ The shortcut toggles exclusive keyboard mode. On entry, the first card is select
|
|
|
116
121
|
|
|
117
122
|
## Interaction contract
|
|
118
123
|
|
|
119
|
-
- **Workspace locality:** Niri focus events trigger immediate reprojection; bounded polling remains a fallback.
|
|
124
|
+
- **Workspace locality:** Niri focus events and workspace list changes trigger immediate reprojection; bounded polling remains a fallback.
|
|
120
125
|
- **Hide/reclaim:** zero cards unmaps the layer surface. Niri then removes its exclusive zone as part of normal Wayland surface lifecycle.
|
|
121
126
|
- **Crash behavior:** panel lifetime is bound to the Node controller through Linux parent-death signaling and stdin EOF. Unexpected panel exits are restarted with bounded backoff; a dead surface cannot retain an exclusive zone.
|
|
122
127
|
- **Exact focus:** card activation returns to Node, which performs existing fail-closed terminal identity resolution and Niri focus.
|
|
123
128
|
- **Hidden tabs:** a Ghostty window title only names its active tab. A bound surface whose title is not visible is placed through its Ghostty host process: one host window is exact containment; several host windows use the window remembered for that tab. Memory comes from titles seen while the strip runs and from a read-only AT-SPI inventory of tab labels, and is persisted per Niri instance under `~/.pi/agent/state/pi-activity-strip/surface-bindings.json`. A tab whose window has never been observed stays unplaced rather than guessed; `status` reports that count. Activating a hidden-tab card calls the host process's `present-surface` action on the session bus, then focuses the window, and reports success only after the title proves the tab is visible.
|
|
129
|
+
- **Window ids:** agents and tools name windows by their Niri window id (`claude-window` prints `window_id`, continuity receipts carry it). Each card shows that id as a quiet `#43` chip at the start of its footer; a hidden tab has no window of its own, so its chip shows the window hosting it. Hover or keyboard detail adds a `window` row with the workspace number the operator sees, Niri's workspace `idx` and never its internal workspace id, for example `#43 · workspace 2` or `host #43 · workspace 2` for a hidden tab. The tooltip and accessible label name the window too. Off Niri there is no window id, so no chip is drawn.
|
|
124
130
|
- **Agent tabs:** a tab is admitted as an agent when the process owning its terminal is a recognized agent CLI, never on a window title alone, so plain terminal programs are not cards. Claude Code tabs are identified exactly through the per-session scratchpad the process holds open, which yields the session id and the title Claude Code put on the terminal; that title places the tab in its window and is remembered so the tab stays placed once hidden.
|
|
125
131
|
- **AK task references:** cards can show the Agent Kernel task their session is working on. The controller reads the read-only `ak` CLI (`ak task list --status claimed --format json --all --verbose` and `ak task deferred --format json --all`) on a calm 15-second clock and joins claims by exact Pi session id under the AK5700 claim semantics: only `session-<uuid>` claims count, and an expired lease is vacant custody that renders nothing. A card whose session holds a live claim shows a clickable `AK #id · title` chip that reuses the card's own activation, so clicking it focuses the exact Ghostty window of the claiming session, presenting hidden tabs first. Claims whose lease has not lapsed but whose claiming session no longer exists ("claim outlives session") and tasks carrying an active deferral are joined by task repo onto cards working inside that repo and render as inert badges, never buttons — there is no live window to focus and no AK action is ever offered. Ambiguous session matches (one logical session resumed into two terminals), a missing `ak` binary, or malformed output bind nothing: fail closed, no chip, no invented state, strip behavior unchanged. The strip never writes AK state and never touches the society database directly.
|
|
126
132
|
- **Codex telemetry:** Codex keeps a thread index naming every session's rollout file, working directory and title. A process binds to its thread by an open rollout descriptor, or, before any task has run, by being the only session created in that directory after the process started; anything ambiguous binds nothing. The rollout tail then reports the running tool and its command, turn count, approval and sandbox policy, prompt and reply. Reading the index needs the runtime's built-in SQLite, and a host without it degrades to a process-only card.
|
|
@@ -167,6 +173,16 @@ The staged artifact receipt binds:
|
|
|
167
173
|
- glibc symbol floor
|
|
168
174
|
- required shared libraries
|
|
169
175
|
|
|
176
|
+
To see the panel without touching the live ribbon, run its built-in demo cards in a nested Niri with a config that spawns nothing:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
printf 'prefer-no-csd\nhotkey-overlay {\n skip-at-startup\n}\n' >/tmp/pi-activity-preview.kdl
|
|
180
|
+
niri -c /tmp/pi-activity-preview.kdl -- env PI_ACTIVITY_STRIP_PANEL_DEMO=1 \
|
|
181
|
+
"$PWD/native/bin/linux-x64-gnu/pi-activity-strip-panel"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The panel is unique per Wayland display: this demo runs beside the live ribbon, while a second panel on the live display is refused with an error rather than reserving a second band. Never run the panel under a private session bus such as `dbus-run-session`: GTK then starts a second accessibility bus that takes over the desktop's AT-SPI socket and leaves it dead when it exits.
|
|
185
|
+
|
|
170
186
|
Live verification must inspect Niri layers rather than regular windows:
|
|
171
187
|
|
|
172
188
|
```bash
|
|
@@ -185,6 +201,7 @@ Implemented:
|
|
|
185
201
|
- exact Ghostty activation, including hidden tabs via `present-surface`
|
|
186
202
|
- hidden Ghostty tab placement through host process containment and learned window memory
|
|
187
203
|
- clickable AK task references joined from read-only `ak` output, with claim-outlives-session and deferred badges
|
|
204
|
+
- Niri window id on every card, and its workspace number in the detail
|
|
188
205
|
- non-Pi agent tab discovery with an exact Claude Code adapter
|
|
189
206
|
- live Claude Code telemetry from its transcript, with optional hooks for blocked-on-you states
|
|
190
207
|
- live Codex telemetry from its thread index and rollout files
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
12
11
|
import { fileURLToPath } from "node:url";
|
|
13
12
|
import {
|
|
14
13
|
getBrokerStatus,
|
|
@@ -16,6 +15,13 @@ import {
|
|
|
16
15
|
requestBrokerShutdown,
|
|
17
16
|
sendBrokerMessage,
|
|
18
17
|
} from "../src/client/broker-client.mjs";
|
|
18
|
+
import {
|
|
19
|
+
RUNTIME_LOCK_CONFLICT_EXIT_CODE,
|
|
20
|
+
RUNTIME_LOCK_PATH as runtimeLockPath,
|
|
21
|
+
stopRuntime,
|
|
22
|
+
waitForRuntimeExit,
|
|
23
|
+
waitForStartedRuntime,
|
|
24
|
+
} from "../src/client/runtime-lock.mjs";
|
|
19
25
|
import {
|
|
20
26
|
assessActivityStripCompatibility,
|
|
21
27
|
formatCompatibilityReport,
|
|
@@ -23,6 +29,7 @@ import {
|
|
|
23
29
|
import {
|
|
24
30
|
ACTIVITY_STRIP_SOCKET_DIR,
|
|
25
31
|
ACTIVITY_STRIP_START_TIMEOUT_MS,
|
|
32
|
+
ACTIVITY_STRIP_STOP_TIMEOUT_MS,
|
|
26
33
|
} from "../src/common/constants.mjs";
|
|
27
34
|
import { makeMessage } from "../src/common/protocol.mjs";
|
|
28
35
|
import { formatBrokerRuntimeStatus } from "../src/common/status-report.mjs";
|
|
@@ -30,7 +37,11 @@ import { formatBrokerRuntimeStatus } from "../src/common/status-report.mjs";
|
|
|
30
37
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
38
|
const __dirname = path.dirname(__filename);
|
|
32
39
|
const nativeEntry = path.resolve(__dirname, "..", "src", "native", "main.mjs");
|
|
33
|
-
const
|
|
40
|
+
const STOP_MESSAGES = {
|
|
41
|
+
stopped: "stopped",
|
|
42
|
+
"not-running": "not-running",
|
|
43
|
+
"still-stopping": "stopping (still shutting down)",
|
|
44
|
+
};
|
|
34
45
|
|
|
35
46
|
function usage() {
|
|
36
47
|
console.log(
|
|
@@ -38,37 +49,6 @@ function usage() {
|
|
|
38
49
|
);
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
async function waitForBrokerReady(timeoutMs = ACTIVITY_STRIP_START_TIMEOUT_MS) {
|
|
42
|
-
const timeoutAt = Date.now() + timeoutMs;
|
|
43
|
-
/** @type {import("../src/common/contracts.ts").BrokerResponse | null} */
|
|
44
|
-
let latestStatus = null;
|
|
45
|
-
|
|
46
|
-
while (Date.now() < timeoutAt) {
|
|
47
|
-
try {
|
|
48
|
-
latestStatus = await getBrokerStatus();
|
|
49
|
-
} catch {
|
|
50
|
-
latestStatus = null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
latestStatus?.ok &&
|
|
55
|
-
(!latestStatus.runtimeStatus || latestStatus.runtimeStatus.state === "ready")
|
|
56
|
-
) {
|
|
57
|
-
return { ok: true };
|
|
58
|
-
}
|
|
59
|
-
if (latestStatus?.runtimeStatus?.state === "error") {
|
|
60
|
-
return { ok: false, error: formatBrokerRuntimeStatus(latestStatus) };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
await delay(125);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
ok: false,
|
|
68
|
-
error: `${formatBrokerRuntimeStatus(latestStatus || { ok: false })}\nTimeout: ${timeoutMs}ms`,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
52
|
/** @param {{ detached?: boolean }} [options] @returns {Promise<number>} */
|
|
73
53
|
async function openStrip({ detached = true } = {}) {
|
|
74
54
|
if (await isBrokerAlive()) {
|
|
@@ -78,11 +58,12 @@ async function openStrip({ detached = true } = {}) {
|
|
|
78
58
|
return 0;
|
|
79
59
|
}
|
|
80
60
|
await requestBrokerShutdown().catch(() => null);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
61
|
+
// Its broker closes before it exits; starting before the lock is released would lose it.
|
|
62
|
+
const exit = await waitForRuntimeExit(runtimeLockPath, {
|
|
63
|
+
timeoutMs: ACTIVITY_STRIP_STOP_TIMEOUT_MS,
|
|
64
|
+
});
|
|
65
|
+
if (!exit.released) {
|
|
66
|
+
console.error(exit.error ?? formatBrokerRuntimeStatus(status));
|
|
86
67
|
return 1;
|
|
87
68
|
}
|
|
88
69
|
}
|
|
@@ -94,26 +75,62 @@ async function openStrip({ detached = true } = {}) {
|
|
|
94
75
|
}
|
|
95
76
|
|
|
96
77
|
fs.mkdirSync(ACTIVITY_STRIP_SOCKET_DIR, { recursive: true, mode: 0o700 });
|
|
97
|
-
const child = spawn(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
78
|
+
const child = spawn(
|
|
79
|
+
"flock",
|
|
80
|
+
[
|
|
81
|
+
"--nonblock",
|
|
82
|
+
"--conflict-exit-code",
|
|
83
|
+
String(RUNTIME_LOCK_CONFLICT_EXIT_CODE),
|
|
84
|
+
runtimeLockPath,
|
|
85
|
+
process.execPath,
|
|
86
|
+
nativeEntry,
|
|
87
|
+
],
|
|
88
|
+
{
|
|
89
|
+
detached,
|
|
90
|
+
stdio: detached ? "ignore" : "inherit",
|
|
91
|
+
env: { ...process.env, PI_ACTIVITY_STRIP_RUNTIME_LOCK_HELD: "1" },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
if (!detached) {
|
|
95
|
+
return await new Promise((resolve) => {
|
|
96
|
+
child.on("exit", (code) => resolve(typeof code === "number" ? code : 0));
|
|
97
|
+
});
|
|
112
98
|
}
|
|
113
99
|
|
|
114
|
-
|
|
115
|
-
|
|
100
|
+
/** @type {{exitCode: number | null | undefined; error?: string}} */
|
|
101
|
+
const controller = { exitCode: undefined };
|
|
102
|
+
child.once("exit", (code) => {
|
|
103
|
+
controller.exitCode = code;
|
|
104
|
+
});
|
|
105
|
+
child.once("error", (error) => {
|
|
106
|
+
controller.exitCode = null;
|
|
107
|
+
controller.error = error.message;
|
|
116
108
|
});
|
|
109
|
+
child.unref();
|
|
110
|
+
const result = await waitForStartedRuntime({
|
|
111
|
+
controller,
|
|
112
|
+
getStatus: () => getBrokerStatus().catch(() => null),
|
|
113
|
+
});
|
|
114
|
+
if (result.ok) {
|
|
115
|
+
console.log(result.started ? "Started activity strip." : "Activity strip is already running.");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (result.reason === "lock-held") {
|
|
119
|
+
console.error(
|
|
120
|
+
"Another activity-strip runtime still holds the runtime lock, probably one that is shutting down; nothing was started. `stop` waits for it to exit.",
|
|
121
|
+
);
|
|
122
|
+
} else if (result.reason === "exited") {
|
|
123
|
+
console.error(
|
|
124
|
+
controller.error
|
|
125
|
+
? `The activity-strip runtime could not be started: ${controller.error}`
|
|
126
|
+
: `The activity-strip runtime exited during startup (code ${result.exitCode}).`,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
const timeout =
|
|
130
|
+
result.reason === "timeout" ? `\nTimeout: ${ACTIVITY_STRIP_START_TIMEOUT_MS}ms` : "";
|
|
131
|
+
console.error(`${formatBrokerRuntimeStatus(result.status ?? { ok: false })}${timeout}`);
|
|
132
|
+
}
|
|
133
|
+
return 1;
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
async function main() {
|
|
@@ -234,14 +251,10 @@ async function main() {
|
|
|
234
251
|
process.exitCode = 0;
|
|
235
252
|
return;
|
|
236
253
|
case "stop": {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
} catch {
|
|
242
|
-
console.log("not-running");
|
|
243
|
-
process.exitCode = 1;
|
|
244
|
-
}
|
|
254
|
+
const result = await stopRuntime();
|
|
255
|
+
if (result.outcome === "error") console.error(result.error);
|
|
256
|
+
else console.log(STOP_MESSAGES[result.outcome]);
|
|
257
|
+
process.exitCode = result.outcome === "stopped" ? 0 : 1;
|
|
245
258
|
return;
|
|
246
259
|
}
|
|
247
260
|
case "-h":
|
|
@@ -14,8 +14,9 @@ import { promisify } from "node:util";
|
|
|
14
14
|
/** @typedef {import("../src/common/contracts.ts").SessionStartContextLike} SessionStartContextLike */
|
|
15
15
|
/** @typedef {import("../src/common/contracts.ts").ToolExecutionEventLike} ToolExecutionEventLike */
|
|
16
16
|
/** @typedef {import("../src/common/contracts.ts").TurnStartEventLike} TurnStartEventLike */
|
|
17
|
-
import { getBrokerStatus
|
|
17
|
+
import { getBrokerStatus } from "../src/client/broker-client.mjs";
|
|
18
18
|
import { ensureActivityStripRunning } from "../src/client/launcher.mjs";
|
|
19
|
+
import { stopRuntime } from "../src/client/runtime-lock.mjs";
|
|
19
20
|
import { createSessionTelemetry } from "../src/client/session-telemetry.mjs";
|
|
20
21
|
import {
|
|
21
22
|
formatBrokerRuntimeStatus,
|
|
@@ -34,6 +35,13 @@ function wantsAutostart() {
|
|
|
34
35
|
return process.env.PI_ACTIVITY_STRIP_AUTO_START !== "0";
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** @type {Record<string, [string, "info" | "warning" | "error"]>} */
|
|
39
|
+
const STOP_NOTICES = {
|
|
40
|
+
stopped: ["Activity strip stopped", "info"],
|
|
41
|
+
"not-running": ["Activity strip is not running", "warning"],
|
|
42
|
+
"still-stopping": ["Activity strip is still shutting down", "warning"],
|
|
43
|
+
};
|
|
44
|
+
|
|
37
45
|
/** @param {ExtensionAPI} pi */
|
|
38
46
|
export default function activityStripExtension(pi) {
|
|
39
47
|
const telemetry = createSessionTelemetry({
|
|
@@ -57,6 +65,20 @@ export default function activityStripExtension(pi) {
|
|
|
57
65
|
return result;
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Returns once the runtime has exited, so an open right after it cannot race a runtime that is
|
|
70
|
+
* still shutting down and leave no ribbon running.
|
|
71
|
+
* @param {UiContextLike} ctx
|
|
72
|
+
*/
|
|
73
|
+
async function stopStrip(ctx) {
|
|
74
|
+
const result = await stopRuntime();
|
|
75
|
+
if (!ctx.hasUI) return;
|
|
76
|
+
/** @type {[string, "info" | "warning" | "error"]} */
|
|
77
|
+
const [message, level] =
|
|
78
|
+
result.outcome === "error" ? [result.error, "error"] : STOP_NOTICES[result.outcome];
|
|
79
|
+
ctx.ui?.notify?.(message, level);
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
pi.registerCommand("activity-strip", {
|
|
61
83
|
description: "Open or check the top-row activity strip",
|
|
62
84
|
/** @param {string} args @param {UiContextLike} ctx */
|
|
@@ -122,16 +144,7 @@ export default function activityStripExtension(pi) {
|
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
if (action === "stop") {
|
|
125
|
-
|
|
126
|
-
const result = await requestBrokerShutdown();
|
|
127
|
-
if (ctx.hasUI)
|
|
128
|
-
ctx.ui?.notify?.(
|
|
129
|
-
result?.ok ? "Stopping activity strip" : "Activity strip is not running",
|
|
130
|
-
result?.ok ? "info" : "warning",
|
|
131
|
-
);
|
|
132
|
-
} catch {
|
|
133
|
-
if (ctx.hasUI) ctx.ui?.notify?.("Activity strip is not running", "warning");
|
|
134
|
-
}
|
|
147
|
+
await stopStrip(ctx);
|
|
135
148
|
return;
|
|
136
149
|
}
|
|
137
150
|
|
|
@@ -143,16 +156,7 @@ export default function activityStripExtension(pi) {
|
|
|
143
156
|
description: "Stop the running activity strip broker/window",
|
|
144
157
|
/** @param {string} _args @param {UiContextLike} ctx */
|
|
145
158
|
handler: async (_args, ctx) => {
|
|
146
|
-
|
|
147
|
-
const result = await requestBrokerShutdown();
|
|
148
|
-
if (ctx.hasUI)
|
|
149
|
-
ctx.ui?.notify?.(
|
|
150
|
-
result?.ok ? "Stopping activity strip" : "Activity strip is not running",
|
|
151
|
-
result?.ok ? "info" : "warning",
|
|
152
|
-
);
|
|
153
|
-
} catch {
|
|
154
|
-
if (ctx.hasUI) ctx.ui?.notify?.("Activity strip is not running", "warning");
|
|
155
|
-
}
|
|
159
|
+
await stopStrip(ctx);
|
|
156
160
|
},
|
|
157
161
|
});
|
|
158
162
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "pi-activity-strip-native-artifact.v1",
|
|
3
3
|
"target": "x86_64-unknown-linux-gnu",
|
|
4
|
-
"sha256": "
|
|
4
|
+
"sha256": "ebf9743947de5c872c3a5accf11bda013de71d081231d8e0f2fea83668227f74",
|
|
5
5
|
"cargoLockSha256": "f8ebc11bc721c569af982169e448a5ea4fed15bc06001017d97acda22b1c890b",
|
|
6
|
-
"sourceSha256": "
|
|
6
|
+
"sourceSha256": "f11355a34e72715d086297217d9cc3caabb81b6dcf3deb1c0c798f3a890f7a33",
|
|
7
7
|
"rustc": "rustc 1.98.0 (88d9e12ae 2026-08-18)",
|
|
8
8
|
"glibcFloor": "GLIBC_2.34",
|
|
9
9
|
"neededSonames": [
|
|
Binary file
|
|
@@ -16,6 +16,7 @@ pub struct CardView {
|
|
|
16
16
|
phase: gtk::Label,
|
|
17
17
|
repo: gtk::Label,
|
|
18
18
|
state: gtk::Label,
|
|
19
|
+
window: gtk::Label,
|
|
19
20
|
tool: gtk::Label,
|
|
20
21
|
elapsed: gtk::Label,
|
|
21
22
|
detail: gtk::Label,
|
|
@@ -23,6 +24,7 @@ pub struct CardView {
|
|
|
23
24
|
reply: gtk::Label,
|
|
24
25
|
path: gtk::Label,
|
|
25
26
|
pid: gtk::Label,
|
|
27
|
+
window_detail: gtk::Label,
|
|
26
28
|
agent: gtk::Label,
|
|
27
29
|
task: gtk::Label,
|
|
28
30
|
activation: gtk::Label,
|
|
@@ -60,9 +62,14 @@ impl CardView {
|
|
|
60
62
|
|
|
61
63
|
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
62
64
|
footer.set_margin_top(5);
|
|
65
|
+
// The Niri window id, so a window an agent or tool names by id can be found on the ribbon.
|
|
66
|
+
// It leads the footer so it sits at the same place on every card and costs the title nothing.
|
|
67
|
+
let window = label("card-window", gtk::Align::Start);
|
|
68
|
+
window.set_visible(false);
|
|
63
69
|
let tool = label("card-tool", gtk::Align::Start);
|
|
64
70
|
tool.set_hexpand(true);
|
|
65
71
|
let elapsed = label("card-elapsed", gtk::Align::End);
|
|
72
|
+
footer.append(&window);
|
|
66
73
|
footer.append(&tool);
|
|
67
74
|
footer.append(&elapsed);
|
|
68
75
|
|
|
@@ -77,11 +84,15 @@ impl CardView {
|
|
|
77
84
|
let reply = inspector_row(&inspector, 2, "reply");
|
|
78
85
|
let path = inspector_row(&inspector, 3, "path");
|
|
79
86
|
let pid = inspector_row(&inspector, 4, "pid");
|
|
80
|
-
let
|
|
81
|
-
|
|
87
|
+
let window_detail = inspector_row(&inspector, 5, "window");
|
|
88
|
+
// Always one short line; a wrapping label would reserve a second line of height for it.
|
|
89
|
+
window_detail.set_wrap(false);
|
|
90
|
+
window_detail.set_lines(-1);
|
|
91
|
+
let agent = inspector_row(&inspector, 6, "agent");
|
|
92
|
+
let task = inspector_row(&inspector, 7, "task");
|
|
82
93
|
let activation = label("activation", gtk::Align::Start);
|
|
83
94
|
activation.set_visible(false);
|
|
84
|
-
inspector.attach(&activation, 0,
|
|
95
|
+
inspector.attach(&activation, 0, 8, 2, 1);
|
|
85
96
|
|
|
86
97
|
content.append(&header);
|
|
87
98
|
content.append(&footer);
|
|
@@ -177,6 +188,7 @@ impl CardView {
|
|
|
177
188
|
phase,
|
|
178
189
|
repo,
|
|
179
190
|
state,
|
|
191
|
+
window,
|
|
180
192
|
tool,
|
|
181
193
|
elapsed,
|
|
182
194
|
detail,
|
|
@@ -184,6 +196,7 @@ impl CardView {
|
|
|
184
196
|
reply,
|
|
185
197
|
path,
|
|
186
198
|
pid,
|
|
199
|
+
window_detail,
|
|
187
200
|
agent,
|
|
188
201
|
task,
|
|
189
202
|
activation,
|
|
@@ -229,6 +242,14 @@ impl CardView {
|
|
|
229
242
|
self.reply.set_text(text_or(&card.assistant_preview, "—"));
|
|
230
243
|
self.path.set_text(text_or(&card.cwd, "—"));
|
|
231
244
|
self.pid.set_text(&pid_row(card, hidden_tab));
|
|
245
|
+
match window_chip_text(card) {
|
|
246
|
+
Some(text) => {
|
|
247
|
+
self.window.set_text(&text);
|
|
248
|
+
self.window.set_visible(true);
|
|
249
|
+
}
|
|
250
|
+
None => self.window.set_visible(false),
|
|
251
|
+
}
|
|
252
|
+
self.window_detail.set_text(&window_row(card));
|
|
232
253
|
self.agent
|
|
233
254
|
.set_text(text_or(&card.agent_label, "Pi session"));
|
|
234
255
|
self.task
|
|
@@ -271,17 +292,20 @@ impl CardView {
|
|
|
271
292
|
"no pid".to_owned()
|
|
272
293
|
};
|
|
273
294
|
let ak_note = ak_accessible_note(&card.ak_tasks);
|
|
295
|
+
let window_note = window_note(card);
|
|
274
296
|
self.root.set_tooltip_text(Some(&format!(
|
|
275
|
-
"{} {} ({}{}){}",
|
|
297
|
+
"{} {} ({}{}{}){}",
|
|
276
298
|
if hidden_tab { "Present" } else { "Focus" },
|
|
277
299
|
text_or(&card.repo_label, "Pi session"),
|
|
278
300
|
pid_note,
|
|
301
|
+
window_note,
|
|
279
302
|
if hidden_tab { ", hidden tab" } else { "" },
|
|
280
303
|
ak_note
|
|
281
304
|
)));
|
|
282
305
|
let accessible_label = format!(
|
|
283
|
-
"{}, {}, {}{}",
|
|
306
|
+
"{}{}, {}, {}{}",
|
|
284
307
|
text_or(&card.repo_label, "Pi session"),
|
|
308
|
+
window_note,
|
|
285
309
|
if stalled {
|
|
286
310
|
"stalled"
|
|
287
311
|
} else {
|
|
@@ -410,6 +434,37 @@ fn pid_row(card: &Card, hidden_tab: bool) -> String {
|
|
|
410
434
|
}
|
|
411
435
|
}
|
|
412
436
|
|
|
437
|
+
/// Compact chip text: the bare Niri window id, as agents and tools print it.
|
|
438
|
+
fn window_chip_text(card: &Card) -> Option<String> {
|
|
439
|
+
card.window_id.map(|id| format!("#{id}"))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/// Inspector row: the window id and the workspace number the operator sees. A hidden tab has no
|
|
443
|
+
/// window of its own, so its id is marked as the window hosting it.
|
|
444
|
+
fn window_row(card: &Card) -> String {
|
|
445
|
+
let Some(window_id) = card.window_id else {
|
|
446
|
+
return "—".to_owned();
|
|
447
|
+
};
|
|
448
|
+
let window = if card.hidden_tab() {
|
|
449
|
+
format!("host #{window_id}")
|
|
450
|
+
} else {
|
|
451
|
+
format!("#{window_id}")
|
|
452
|
+
};
|
|
453
|
+
match card.workspace_idx {
|
|
454
|
+
Some(workspace) => format!("{window} · workspace {workspace}"),
|
|
455
|
+
None => window,
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/// Appended to the card's tooltip and accessible label when the window is known.
|
|
460
|
+
fn window_note(card: &Card) -> String {
|
|
461
|
+
match card.window_id {
|
|
462
|
+
Some(id) if card.hidden_tab() => format!(", host window {id}"),
|
|
463
|
+
Some(id) => format!(", window {id}"),
|
|
464
|
+
None => String::new(),
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
413
468
|
fn state_label(state: &str) -> &str {
|
|
414
469
|
match state {
|
|
415
470
|
"success" => "done",
|
|
@@ -547,6 +602,39 @@ mod tests {
|
|
|
547
602
|
);
|
|
548
603
|
}
|
|
549
604
|
|
|
605
|
+
#[test]
|
|
606
|
+
fn window_chip_names_the_niri_window_and_the_detail_adds_its_workspace() {
|
|
607
|
+
let visible = card(serde_json::json!({
|
|
608
|
+
"repoLabel": "dspx", "windowId": 43, "workspaceIdx": 2, "surfaceVisible": true
|
|
609
|
+
}));
|
|
610
|
+
assert_eq!(window_chip_text(&visible).as_deref(), Some("#43"));
|
|
611
|
+
assert_eq!(window_row(&visible), "#43 · workspace 2");
|
|
612
|
+
assert_eq!(window_note(&visible), ", window 43");
|
|
613
|
+
|
|
614
|
+
let hidden = card(serde_json::json!({
|
|
615
|
+
"repoLabel": "dspx", "windowId": 43, "workspaceIdx": 2, "surfaceVisible": false
|
|
616
|
+
}));
|
|
617
|
+
assert_eq!(
|
|
618
|
+
window_chip_text(&hidden).as_deref(),
|
|
619
|
+
Some("#43"),
|
|
620
|
+
"a hidden tab shows the window hosting it"
|
|
621
|
+
);
|
|
622
|
+
assert_eq!(window_row(&hidden), "host #43 · workspace 2");
|
|
623
|
+
assert_eq!(window_note(&hidden), ", host window 43");
|
|
624
|
+
|
|
625
|
+
let unindexed = card(serde_json::json!({ "windowId": 43 }));
|
|
626
|
+
assert_eq!(window_row(&unindexed), "#43");
|
|
627
|
+
|
|
628
|
+
let unplaced = card(serde_json::json!({ "repoLabel": "dspx" }));
|
|
629
|
+
assert_eq!(
|
|
630
|
+
window_chip_text(&unplaced),
|
|
631
|
+
None,
|
|
632
|
+
"no chip without a window"
|
|
633
|
+
);
|
|
634
|
+
assert_eq!(window_row(&unplaced), "—");
|
|
635
|
+
assert_eq!(window_note(&unplaced), "");
|
|
636
|
+
}
|
|
637
|
+
|
|
550
638
|
#[test]
|
|
551
639
|
fn ak_chip_states_map_to_classes_and_only_active_is_clickable() {
|
|
552
640
|
let active = AkTask {
|
package/native/panel/src/main.rs
CHANGED
|
@@ -19,13 +19,76 @@ fn bind_lifetime_to_parent() {
|
|
|
19
19
|
#[cfg(not(target_os = "linux"))]
|
|
20
20
|
fn bind_lifetime_to_parent() {}
|
|
21
21
|
|
|
22
|
+
const APPLICATION_ID: &str = "com.tryinget.pi-activity-strip.panel";
|
|
23
|
+
/// Keeps the display element well inside the 255-byte limit of a D-Bus name.
|
|
24
|
+
const DISPLAY_ELEMENT_MAX: usize = 64;
|
|
25
|
+
|
|
26
|
+
/// GTK uniqueness is scoped to one Wayland display. A second panel on the same compositor is
|
|
27
|
+
/// refused, so no second ribbon can reserve another band, while a panel inside a nested
|
|
28
|
+
/// compositor (the demo, a preview, a test) runs on its own instead of handing itself off.
|
|
29
|
+
fn application_id(wayland_display: Option<&str>) -> String {
|
|
30
|
+
// GTK connects to `wayland-0` when the variable is unset, and a socket path names the same
|
|
31
|
+
// compositor as its file name, so every spelling of one display shares one id.
|
|
32
|
+
let raw = wayland_display
|
|
33
|
+
.filter(|display| !display.is_empty())
|
|
34
|
+
.unwrap_or("wayland-0");
|
|
35
|
+
let name = raw.rsplit('/').next().unwrap_or(raw);
|
|
36
|
+
let display: String = name
|
|
37
|
+
.chars()
|
|
38
|
+
.take(DISPLAY_ELEMENT_MAX)
|
|
39
|
+
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
|
40
|
+
.collect();
|
|
41
|
+
format!("{APPLICATION_ID}.d_{display}")
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
fn main() {
|
|
23
45
|
bind_lifetime_to_parent();
|
|
24
46
|
let demo = std::env::var("PI_ACTIVITY_STRIP_PANEL_DEMO").as_deref() == Ok("1");
|
|
25
47
|
let click_through = std::env::var("PI_ACTIVITY_STRIP_CLICK_THROUGH").as_deref() == Ok("1");
|
|
26
|
-
let
|
|
48
|
+
let display = std::env::var("WAYLAND_DISPLAY").ok();
|
|
49
|
+
let app = RelmApp::new(&application_id(display.as_deref())).visible_on_activate(false);
|
|
27
50
|
app.run::<App>(AppInit {
|
|
28
51
|
demo,
|
|
29
52
|
click_through,
|
|
30
53
|
});
|
|
54
|
+
// A second panel on this display hands its activation to the first and returns without ever
|
|
55
|
+
// starting. It used to exit 0 having drawn nothing; say so and fail instead. Registration can
|
|
56
|
+
// also fail this way, so the message names the likely cause rather than asserting it.
|
|
57
|
+
if !protocol::was_ready() {
|
|
58
|
+
protocol::emit_error(
|
|
59
|
+
"The panel returned without starting; another activity-strip panel probably already runs on this Wayland display.",
|
|
60
|
+
);
|
|
61
|
+
std::process::exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[cfg(test)]
|
|
66
|
+
mod tests {
|
|
67
|
+
use super::application_id;
|
|
68
|
+
|
|
69
|
+
#[test]
|
|
70
|
+
fn uniqueness_is_scoped_to_the_wayland_display() {
|
|
71
|
+
assert_eq!(
|
|
72
|
+
application_id(Some("wayland-1")),
|
|
73
|
+
"com.tryinget.pi-activity-strip.panel.d_wayland_1"
|
|
74
|
+
);
|
|
75
|
+
assert_ne!(
|
|
76
|
+
application_id(Some("wayland-1")),
|
|
77
|
+
application_id(Some("wayland-2")),
|
|
78
|
+
"a panel in a nested compositor never hands off to the live one"
|
|
79
|
+
);
|
|
80
|
+
assert_eq!(
|
|
81
|
+
application_id(Some("/run/user/1000/wayland-1")),
|
|
82
|
+
application_id(Some("wayland-1")),
|
|
83
|
+
"a socket path and its name are the same compositor"
|
|
84
|
+
);
|
|
85
|
+
assert_eq!(
|
|
86
|
+
application_id(None),
|
|
87
|
+
application_id(Some("wayland-0")),
|
|
88
|
+
"GTK connects to wayland-0 when the variable is unset"
|
|
89
|
+
);
|
|
90
|
+
assert_eq!(application_id(Some("")), application_id(Some("wayland-0")));
|
|
91
|
+
let long = application_id(Some(&"x".repeat(400)));
|
|
92
|
+
assert!(long.len() <= 255, "a D-Bus name is at most 255 bytes");
|
|
93
|
+
}
|
|
31
94
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
use serde::Deserialize;
|
|
2
2
|
use serde_json::{Value, json};
|
|
3
3
|
use std::io::{self, Write};
|
|
4
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
4
5
|
use std::sync::{Mutex, OnceLock};
|
|
5
6
|
|
|
6
7
|
#[derive(Clone, Debug, Deserialize)]
|
|
@@ -73,6 +74,22 @@ pub struct Card {
|
|
|
73
74
|
/// placed it through its host process, so activation presents the tab before focusing.
|
|
74
75
|
#[serde(default)]
|
|
75
76
|
pub surface_visible: Option<bool>,
|
|
77
|
+
/// Niri window id, so a window an agent or tool names by id can be found on the ribbon. A
|
|
78
|
+
/// hidden tab has no window of its own and carries the id of the window hosting it.
|
|
79
|
+
#[serde(default, deserialize_with = "lenient_integer")]
|
|
80
|
+
pub window_id: Option<i64>,
|
|
81
|
+
/// Niri workspace `idx`: the number the operator sees, not the internal workspace id.
|
|
82
|
+
#[serde(default, deserialize_with = "lenient_integer")]
|
|
83
|
+
pub workspace_idx: Option<i64>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// An optional label is not worth a view: a value that is not an integer drops the field rather
|
|
87
|
+
/// than failing the whole message and every card in it.
|
|
88
|
+
fn lenient_integer<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
|
|
89
|
+
where
|
|
90
|
+
D: serde::Deserializer<'de>,
|
|
91
|
+
{
|
|
92
|
+
Ok(Value::deserialize(deserializer)?.as_i64())
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
impl Card {
|
|
@@ -130,6 +147,7 @@ fn protocol_version() -> u8 {
|
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
static OUTPUT: OnceLock<Mutex<io::Stdout>> = OnceLock::new();
|
|
150
|
+
static READY: AtomicBool = AtomicBool::new(false);
|
|
133
151
|
|
|
134
152
|
pub fn emit(event: Value) {
|
|
135
153
|
let stdout = OUTPUT.get_or_init(|| Mutex::new(io::stdout()));
|
|
@@ -140,7 +158,14 @@ pub fn emit(event: Value) {
|
|
|
140
158
|
}
|
|
141
159
|
}
|
|
142
160
|
|
|
161
|
+
/// Whether this panel ever came up. GTK returns from `run` without building anything when it hands
|
|
162
|
+
/// the activation to another panel on the same display.
|
|
163
|
+
pub fn was_ready() -> bool {
|
|
164
|
+
READY.load(Ordering::SeqCst)
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
pub fn emit_ready() {
|
|
168
|
+
READY.store(true, Ordering::SeqCst);
|
|
144
169
|
emit(json!({
|
|
145
170
|
"protocol": 1,
|
|
146
171
|
"type": "ready",
|
|
@@ -177,6 +202,8 @@ pub fn demo_view() -> ViewMessage {
|
|
|
177
202
|
started_at: now - 135_000,
|
|
178
203
|
last_event_at: now - 4_000,
|
|
179
204
|
updated_at: now,
|
|
205
|
+
window_id: Some(36),
|
|
206
|
+
workspace_idx: Some(2),
|
|
180
207
|
..empty_card()
|
|
181
208
|
},
|
|
182
209
|
Card {
|
|
@@ -202,6 +229,26 @@ pub fn demo_view() -> ViewMessage {
|
|
|
202
229
|
state: "active".into(),
|
|
203
230
|
}],
|
|
204
231
|
ak_task_overflow: 1,
|
|
232
|
+
window_id: Some(43),
|
|
233
|
+
workspace_idx: Some(2),
|
|
234
|
+
..empty_card()
|
|
235
|
+
},
|
|
236
|
+
Card {
|
|
237
|
+
card_id: "demo-hidden".into(),
|
|
238
|
+
session_id: "demo-hidden".into(),
|
|
239
|
+
repo_label: "pi-extensions".into(),
|
|
240
|
+
phase: "Waiting behind another tab".into(),
|
|
241
|
+
state: "idle".into(),
|
|
242
|
+
detail: "Hidden tab placed through its Ghostty host window".into(),
|
|
243
|
+
cwd: "/home/tryinget/ai-society/softwareco/owned/pi-extensions".into(),
|
|
244
|
+
started_at: now - 610_000,
|
|
245
|
+
last_event_at: now - 95_000,
|
|
246
|
+
updated_at: now,
|
|
247
|
+
pid: 48213,
|
|
248
|
+
agent_label: "Claude Code".into(),
|
|
249
|
+
surface_visible: Some(false),
|
|
250
|
+
window_id: Some(43),
|
|
251
|
+
workspace_idx: Some(2),
|
|
205
252
|
..empty_card()
|
|
206
253
|
},
|
|
207
254
|
],
|
|
@@ -231,6 +278,8 @@ fn empty_card() -> Card {
|
|
|
231
278
|
ak_tasks: Vec::new(),
|
|
232
279
|
ak_task_overflow: 0,
|
|
233
280
|
surface_visible: None,
|
|
281
|
+
window_id: None,
|
|
282
|
+
workspace_idx: None,
|
|
234
283
|
}
|
|
235
284
|
}
|
|
236
285
|
|
|
@@ -253,6 +302,36 @@ mod tests {
|
|
|
253
302
|
assert_eq!(view.sessions[0].ak_task_overflow, 0);
|
|
254
303
|
}
|
|
255
304
|
|
|
305
|
+
#[test]
|
|
306
|
+
fn window_and_workspace_numbers_are_optional_card_fields() {
|
|
307
|
+
let view: ViewMessage = serde_json::from_str(
|
|
308
|
+
r#"{"protocol":1,"type":"view","revision":9,"sessions":[{"cardId":"card-a","windowId":43,"workspaceIdx":2},{"cardId":"card-b","windowId":null,"workspaceIdx":null},{"cardId":"card-c"}]}"#,
|
|
309
|
+
)
|
|
310
|
+
.expect("view should parse");
|
|
311
|
+
assert_eq!(view.sessions[0].window_id, Some(43));
|
|
312
|
+
assert_eq!(view.sessions[0].workspace_idx, Some(2));
|
|
313
|
+
assert_eq!(view.sessions[1].window_id, None);
|
|
314
|
+
assert_eq!(view.sessions[1].workspace_idx, None);
|
|
315
|
+
assert_eq!(
|
|
316
|
+
view.sessions[2].window_id, None,
|
|
317
|
+
"controllers that send no window id still parse"
|
|
318
|
+
);
|
|
319
|
+
assert_eq!(view.sessions[2].workspace_idx, None);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn a_malformed_window_or_workspace_number_drops_only_that_field() {
|
|
324
|
+
let view: ViewMessage = serde_json::from_str(
|
|
325
|
+
r#"{"protocol":1,"type":"view","revision":10,"sessions":[{"cardId":"card-a","windowId":"43","workspaceIdx":2.5},{"cardId":"card-b","windowId":44,"workspaceIdx":3}]}"#,
|
|
326
|
+
)
|
|
327
|
+
.expect("a bad optional field must not reject the whole view");
|
|
328
|
+
assert_eq!(view.sessions.len(), 2);
|
|
329
|
+
assert_eq!(view.sessions[0].window_id, None);
|
|
330
|
+
assert_eq!(view.sessions[0].workspace_idx, None);
|
|
331
|
+
assert_eq!(view.sessions[1].window_id, Some(44));
|
|
332
|
+
assert_eq!(view.sessions[1].workspace_idx, Some(3));
|
|
333
|
+
}
|
|
334
|
+
|
|
256
335
|
#[test]
|
|
257
336
|
fn ak_task_chips_parse_with_states() {
|
|
258
337
|
let view: ViewMessage = serde_json::from_str(
|
|
@@ -30,6 +30,7 @@ window {
|
|
|
30
30
|
.brand-eyebrow,
|
|
31
31
|
.meta,
|
|
32
32
|
.card-state,
|
|
33
|
+
.card-window,
|
|
33
34
|
.card-tool,
|
|
34
35
|
.card-elapsed,
|
|
35
36
|
.inspector-key {
|
|
@@ -184,6 +185,18 @@ window {
|
|
|
184
185
|
font-size: 8px;
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
/*
|
|
189
|
+
* The Niri window id, for finding a window an agent or tool names by id. A tint rather than a
|
|
190
|
+
* border and no vertical padding, so it reads as a chip without growing the footer line.
|
|
191
|
+
*/
|
|
192
|
+
.card-window {
|
|
193
|
+
background: alpha(@pi_foreground, 0.09);
|
|
194
|
+
border-radius: 999px;
|
|
195
|
+
color: alpha(@pi_foreground, 0.7);
|
|
196
|
+
font-size: 8px;
|
|
197
|
+
padding: 0 5px;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
.inspector {
|
|
188
201
|
border-top: 1px solid alpha(@pi_foreground, 0.12);
|
|
189
202
|
padding-top: 9px;
|
package/package.json
CHANGED
|
@@ -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);
|