@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.
Files changed (46) hide show
  1. package/README.md +59 -7
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +98 -64
  4. package/extensions/activity-strip.js +25 -21
  5. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/src/app.rs +20 -20
  8. package/native/panel/src/card_view.rs +396 -9
  9. package/native/panel/src/main.rs +64 -1
  10. package/native/panel/src/protocol.rs +142 -0
  11. package/native/panel/src/runtime.rs +50 -9
  12. package/native/panel/src/style.css +147 -60
  13. package/package.json +4 -4
  14. package/src/client/runtime-lock.mjs +140 -0
  15. package/src/common/agent-identity.mjs +163 -0
  16. package/src/common/ak-tasks.mjs +354 -0
  17. package/src/common/claude-events.mjs +190 -0
  18. package/src/common/claude-hook-config.mjs +56 -0
  19. package/src/common/claude-transcript.mjs +163 -0
  20. package/src/common/codex-transcript.mjs +153 -0
  21. package/src/common/compatibility.mjs +13 -0
  22. package/src/common/constants.mjs +2 -0
  23. package/src/common/contracts.d.ts +35 -0
  24. package/src/common/contracts.ts +67 -1
  25. package/src/common/ghostty-present.mjs +100 -0
  26. package/src/common/ghostty-theme.mjs +149 -0
  27. package/src/common/niri-focus.mjs +179 -111
  28. package/src/common/session-cards.mjs +5 -2
  29. package/src/common/status-report.mjs +51 -0
  30. package/src/common/surface-bindings.mjs +234 -0
  31. package/src/common/terminal-identity.mjs +75 -12
  32. package/src/common/window-placement.mjs +59 -0
  33. package/src/common/workspace-view.mjs +161 -0
  34. package/src/native/agent-discovery.mjs +294 -0
  35. package/src/native/ak-runtime.mjs +164 -0
  36. package/src/native/codex-discovery.mjs +128 -0
  37. package/src/native/ghostty-tab-inventory.py +99 -0
  38. package/src/native/height-repair.mjs +154 -0
  39. package/src/native/main.mjs +122 -64
  40. package/src/native/panel-binary.mjs +56 -0
  41. package/src/native/panel-projection.mjs +70 -5
  42. package/src/native/placement.mjs +225 -0
  43. package/src/native/tab-inventory.mjs +188 -0
  44. package/src/native/theme-runtime.mjs +72 -0
  45. package/src/native/theme-source.mjs +197 -0
  46. package/src/native/workspace-events.mjs +22 -1
package/README.md CHANGED
@@ -12,21 +12,27 @@ system4d:
12
12
 
13
13
  # @tryinget/pi-activity-strip
14
14
 
15
- A screen-top activity ribbon for live Pi sessions running in Ghostty.
15
+ A screen-top activity ribbon for the coding agents running in your Ghostty tabs. Pi sessions publish their own telemetry; other terminal agents such as Claude Code are discovered from the process table.
16
16
 
17
17
  The runtime is Electron-free. A Node controller retains the tested telemetry, identity, ordering, and exact-focus logic; a small Rust/Relm4/GTK4 panel owns rendering and the Wayland layer-shell surface.
18
18
 
19
19
  ## What it does
20
20
 
21
21
  - auto-starts with interactive Pi TUI sessions
22
- - shows one card per admitted Ghostty terminal on the focused Niri workspace
22
+ - shows one card per admitted Ghostty terminal on the focused Niri workspace, including tabs hidden behind another tab of their Ghostty window
23
+ - covers Pi sessions and other terminal agents (Claude Code, Codex, Gemini and more) found in Ghostty tabs
24
+ - restores tiled windows left at the wrong height when its reserved band appears or disappears
25
+ - draws itself in Ghostty's own theme and follows the desktop between light and dark
26
+ - sits on the same gap rhythm and corner radius as tiled windows, rather than as a bar on the screen edge
23
27
  - aggregates independent publishers beneath stable terminal cards
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
24
29
  - displays repo, phase, tool, detail, elapsed time, and freshness
25
- - marks the exact currently focused terminal card
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
26
32
  - keeps monitoring-success cards beside the Activity tile, then active and settled cards
27
33
  - expands rich details on hover or keyboard focus
28
34
  - supports Left/Right navigation and Shift+Left/Right manual movement
29
- - focuses the exact matching Ghostty window on click or Enter
35
+ - focuses the exact matching Ghostty window on click or Enter, presenting a hidden tab first
30
36
  - hides completely on workspaces without tracked cards
31
37
  - releases its 84px exclusive zone automatically when hidden or crashed
32
38
 
@@ -41,9 +47,11 @@ Pi publisher streams
41
47
  -> wlr-layer-shell top surface with an 84px exclusive zone
42
48
  ```
43
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
+
44
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.
45
53
 
46
- The compact surface is 84px tall. One engaged card expands the surface to 252px while the exclusive zone remains 84px, 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.
47
55
 
48
56
  ## Supported host
49
57
 
@@ -91,9 +99,12 @@ node ./bin/pi-activity-strip.mjs focus-session <full-pi-session-id>
91
99
  node ./bin/pi-activity-strip.mjs status
92
100
  node ./bin/pi-activity-strip.mjs doctor
93
101
  node ./bin/pi-activity-strip.mjs snapshot
102
+ node ./bin/pi-activity-strip.mjs claude-hooks
94
103
  node ./bin/pi-activity-strip.mjs stop
95
104
  ```
96
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
+
97
108
  `fix-top` is now a compatibility no-op: layer-shell placement is compositor-owned.
98
109
 
99
110
  ## Keyboard-only entry
@@ -110,10 +121,19 @@ The shortcut toggles exclusive keyboard mode. On entry, the first card is select
110
121
 
111
122
  ## Interaction contract
112
123
 
113
- - **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.
114
125
  - **Hide/reclaim:** zero cards unmaps the layer surface. Niri then removes its exclusive zone as part of normal Wayland surface lifecycle.
115
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.
116
127
  - **Exact focus:** card activation returns to Node, which performs existing fail-closed terminal identity resolution and Niri focus.
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.
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.
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.
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.
133
+ - **Claude Code telemetry:** cards read live state from the tail of the session transcript, giving the topic, current tool and its target, last prompt, latest reply, turn count and activity clock with no configuration. That format is internal to Claude Code and can change between releases, so a transcript that no longer parses degrades to a process-only card rather than inventing activity. Optional hooks add the one state a transcript cannot express, that a session is blocked waiting for you; run `claude-hooks` for the settings fragment. Only low-frequency events are hooked, so nothing runs per tool call. OpenTelemetry is deliberately not used: it reports aggregate usage and cost, not which tool a session is running now.
134
+ - **Appearance:** the ribbon reads the same theme files Ghostty reads. It resolves the `theme` setting for the desktop's current colour scheme, including the `light:…,dark:…` form, and takes colours set directly in the config over the theme file, exactly as Ghostty layers them. State colours reuse the terminal's own meanings, so green is settled, yellow is working, red failed, and the cursor colour marks a session waiting for you. The panel derives every shade from eight named colours, so a theme change is a handful of values and one stylesheet reload rather than a restart. A theme that cannot be read falls back to a neutral palette.
135
+ - **Height repair:** showing or hiding the ribbon changes the output working area, and a window whose height is not automatic keeps the old value. After each change the strip compares window heights before and after, and resets to automatic only those windows still sitting at a height the moving windows just vacated. A height nobody vacated is never touched, each window is reset at most once per height, and a pass is bounded.
136
+ - **Two identity keys:** a Ghostty surface id is a per-process handle that can drift away from the value a long-lived Pi process captured at startup, while the 32-hex session token in the same title never does. Memory therefore stores both, and lookups prefer the exact surface. A session token that two windows claim is ambiguous and binds nothing; two terminals resuming one logical session in the same window are both placed there.
117
137
  - **Ordering:** monitoring, active, and settled groups refresh on a calm 15-second clock. Manual moves survive until regroup or restart.
118
138
  - **Accessibility:** cards expose native GTK labels, selected/expanded state, activation descriptions, and GTK accessible announcements.
119
139
 
@@ -123,6 +143,15 @@ The shortcut toggles exclusive keyboard mode. On entry, the first card is select
123
143
  - `PI_ACTIVITY_STRIP_CLICK_THROUGH=1` installs an empty Wayland input region and disables keyboard entry.
124
144
  - `PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN=/absolute/path` selects another receipted panel artifact.
125
145
  - `PI_ACTIVITY_STRIP_SOCKET_DIR` and `PI_ACTIVITY_STRIP_SOCKET_PATH` isolate broker fixtures and nested-compositor tests.
146
+ - `PI_ACTIVITY_STRIP_TAB_INVENTORY=0` disables the read-only AT-SPI tab inventory; hidden tabs are then placed only from titles seen while the strip runs.
147
+ - `PI_ACTIVITY_STRIP_AK_TASKS=0` disables AK task reference joining.
148
+ - `PI_ACTIVITY_STRIP_AK_BIN=/absolute/path` selects the `ak` binary used for the read-only task queries (default `ak` on `PATH`).
149
+ - `PI_ACTIVITY_STRIP_AGENT_TABS=0` disables discovery of non-Pi agent tabs.
150
+ - `PI_ACTIVITY_STRIP_AGENT_KINDS_DISABLED=claude,codex` excludes named agent kinds from discovery.
151
+ - `CODEX_HOME` selects a non-default Codex home when reading its thread index (default `~/.codex`).
152
+ - `PI_ACTIVITY_STRIP_PYTHON` selects the interpreter for the tab inventory (default `python3`).
153
+ - `PI_ACTIVITY_STRIP_HEIGHT_REPAIR=0` disables restoring windows stranded at the previous working-area height.
154
+ - `PI_ACTIVITY_STRIP_COLOR_SCHEME=light|dark` pins the ribbon to one scheme instead of following the desktop.
126
155
 
127
156
  Unverified native binaries are rejected unless `PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL=1` is explicitly set for development fixtures.
128
157
 
@@ -144,6 +173,16 @@ The staged artifact receipt binds:
144
173
  - glibc symbol floor
145
174
  - required shared libraries
146
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
+
147
186
  Live verification must inspect Niri layers rather than regular windows:
148
187
 
149
188
  ```bash
@@ -159,14 +198,27 @@ Implemented:
159
198
  - workspace-local Niri projection
160
199
  - hide/reclaim and restore
161
200
  - pointer and keyboard card interaction
162
- - exact Ghostty activation
201
+ - exact Ghostty activation, including hidden tabs via `present-surface`
202
+ - hidden Ghostty tab placement through host process containment and learned window memory
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
205
+ - non-Pi agent tab discovery with an exact Claude Code adapter
206
+ - live Claude Code telemetry from its transcript, with optional hooks for blocked-on-you states
207
+ - live Codex telemetry from its thread index and rollout files
208
+ - Ghostty theme following, light and dark
209
+ - repair of windows stranded at the previous working-area height
163
210
  - bounded panel restart and parent-death cleanup
164
211
  - click-through input region
165
212
 
166
213
  Not implemented:
167
214
 
215
+ - placement of a tab whose window has never been observed, on a host without the AT-SPI inventory (Ghostty exposes no surface listing of its own)
216
+ - distinguishing two terminals that resume one logical session when only the drifted session token is available; both are placed in the one window that claims that token
217
+ - live phase and tool detail for agents other than Claude Code and Codex, which have no adapter yet; their cards show the agent, directory, elapsed time and pid
218
+ - placement of an agent other than Claude Code whose tab is hidden inside a multi-window Ghostty process, since only Claude Code exposes a per-tab title identity
168
219
  - one panel per output
169
220
  - historical timeline
221
+ - any AK mutation from the strip (unclaim, land, reconstruct, apply); the ribbon is a read-only projection by construction
170
222
  - persisted manual ordering
171
223
  - remote observers via `pi-server`
172
224
 
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // ---
3
+ // summary: "Claude Code hook entrypoint that records one session's live state for the activity ribbon"
4
+ // read_when:
5
+ // - "changing how Claude Code sessions publish live state to the ribbon"
6
+ // ---
7
+
8
+ // Claude Code runs this for each configured hook event and waits for it, so it must be cheap and
9
+ // must never fail the session: every path exits 0 and nothing is written to stdout.
10
+
11
+ import fs from "node:fs";
12
+ import {
13
+ CLAUDE_EVENT_DIR,
14
+ claudeEventPath,
15
+ claudeEventRecord,
16
+ isSessionEndEvent,
17
+ } from "../src/common/claude-events.mjs";
18
+
19
+ const STDIN_LIMIT_BYTES = 1024 * 1024;
20
+
21
+ async function readPayload() {
22
+ const chunks = [];
23
+ let total = 0;
24
+ for await (const chunk of process.stdin) {
25
+ total += chunk.length;
26
+ if (total > STDIN_LIMIT_BYTES) break;
27
+ chunks.push(chunk);
28
+ }
29
+ try {
30
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ async function main() {
37
+ const payload = await readPayload();
38
+ if (!payload || typeof payload !== "object") return;
39
+ const record = claudeEventRecord(payload, { env: process.env });
40
+ if (!record) return;
41
+ const filePath = claudeEventPath(record.sessionId);
42
+ if (!filePath) return;
43
+
44
+ if (isSessionEndEvent(record)) {
45
+ try {
46
+ fs.unlinkSync(filePath);
47
+ } catch {
48
+ // The session never published, or another hook already retired it.
49
+ }
50
+ return;
51
+ }
52
+
53
+ const temporaryPath = `${filePath}.tmp.${process.pid}`;
54
+ try {
55
+ fs.mkdirSync(CLAUDE_EVENT_DIR, { recursive: true, mode: 0o700 });
56
+ fs.writeFileSync(temporaryPath, JSON.stringify(record), { mode: 0o600 });
57
+ fs.renameSync(temporaryPath, filePath);
58
+ } catch {
59
+ try {
60
+ fs.unlinkSync(temporaryPath);
61
+ } catch {
62
+ // Nothing was staged.
63
+ }
64
+ }
65
+ }
66
+
67
+ main()
68
+ .catch(() => {})
69
+ .finally(() => {
70
+ process.exitCode = 0;
71
+ });
@@ -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,45 +37,18 @@ 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 runtimeLockPath = path.join(ACTIVITY_STRIP_SOCKET_DIR, "runtime.lock");
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(
37
- `Usage: pi-activity-strip <open|focus-strip|focus-session|status|doctor|snapshot|fix-top|stop|serve> [options]\n\nCommands:\n open Start the interactive top-row activity strip (--click-through opts out)\n focus-strip Focus the visible strip already resident on the focused Niri workspace\n focus-session ID Focus the one Ghostty/Niri window matching an exact Pi session identity\n status Check broker + overlay readiness and surface runtime warnings\n doctor Inspect host compatibility assumptions before opening the strip\n snapshot Print the current broker snapshot as JSON\n fix-top Confirm compositor-owned layer-shell placement\n stop Ask the running strip to shut down\n serve Internal helper; starts the native runtime in the foreground\n`,
48
+ `Usage: pi-activity-strip <open|focus-strip|focus-session|status|doctor|snapshot|claude-hooks|fix-top|stop|serve> [options]\n\nCommands:\n open Start the interactive top-row activity strip (--click-through opts out)\n focus-strip Focus the visible strip already resident on the focused Niri workspace\n focus-session ID Focus the one Ghostty/Niri window matching an exact Pi session identity\n status Check broker + overlay readiness and surface runtime warnings\n doctor Inspect host compatibility assumptions before opening the strip\n snapshot Print the current broker snapshot as JSON\n claude-hooks Print the Claude Code settings fragment for live agent telemetry\n fix-top Confirm compositor-owned layer-shell placement\n stop Ask the running strip to shut down\n serve Internal helper; starts the native runtime in the foreground\n`,
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
- for (let attempt = 0; attempt < 20 && (await isBrokerAlive()); attempt += 1) {
82
- await delay(50);
83
- }
84
- if (await isBrokerAlive()) {
85
- console.error(formatBrokerRuntimeStatus(status));
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("flock", ["--nonblock", runtimeLockPath, process.execPath, nativeEntry], {
98
- detached,
99
- stdio: detached ? "ignore" : "inherit",
100
- env: { ...process.env, PI_ACTIVITY_STRIP_RUNTIME_LOCK_HELD: "1" },
101
- });
102
-
103
- if (detached) {
104
- child.unref();
105
- const ready = await waitForBrokerReady();
106
- if (!ready.ok) {
107
- console.error(ready.error || "Activity strip did not become ready.");
108
- return 1;
109
- }
110
- console.log("Started activity strip.");
111
- return 0;
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
- return await new Promise((resolve) => {
115
- child.on("exit", (code) => resolve(typeof code === "number" ? code : 0));
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() {
@@ -208,19 +225,36 @@ async function main() {
208
225
  }
209
226
  return;
210
227
  }
228
+ case "claude-hooks": {
229
+ const { claudeHookSettings } = await import("../src/common/claude-hook-config.mjs");
230
+ const hookPath = path.join(
231
+ path.dirname(fileURLToPath(import.meta.url)),
232
+ "pi-activity-strip-claude-hook.mjs",
233
+ );
234
+ const settings = claudeHookSettings(`${process.execPath} ${hookPath}`);
235
+ if (jsonOutput) {
236
+ console.log(JSON.stringify(settings, null, 2));
237
+ } else {
238
+ console.log(
239
+ "Merge this into ~/.claude/settings.json so Claude Code sessions report live state:\n",
240
+ );
241
+ console.log(JSON.stringify(settings, null, 2));
242
+ console.log(
243
+ "\nOnly low-frequency events are hooked, so nothing runs per tool call. Tool and thinking",
244
+ );
245
+ console.log("state already come from the transcript without any configuration.");
246
+ }
247
+ return;
248
+ }
211
249
  case "fix-top":
212
250
  console.log("Native layer-shell placement is compositor-owned; no repair was needed.");
213
251
  process.exitCode = 0;
214
252
  return;
215
253
  case "stop": {
216
- try {
217
- const result = await requestBrokerShutdown();
218
- console.log(result?.ok ? "stopping" : "not-running");
219
- process.exitCode = result?.ok ? 0 : 1;
220
- } catch {
221
- console.log("not-running");
222
- process.exitCode = 1;
223
- }
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;
224
258
  return;
225
259
  }
226
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, requestBrokerShutdown } from "../src/client/broker-client.mjs";
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
- try {
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
- try {
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": "4cc72663dda90369a3a3ad60aabeefc4a609f42c6cc4a4661d2dc052570a3f0a",
4
+ "sha256": "ebf9743947de5c872c3a5accf11bda013de71d081231d8e0f2fea83668227f74",
5
5
  "cargoLockSha256": "f8ebc11bc721c569af982169e448a5ea4fed15bc06001017d97acda22b1c890b",
6
- "sourceSha256": "9467e315aa20fef76c25b88d29e69f3ef6960481b55b639c602e77805986f0e1",
6
+ "sourceSha256": "f11355a34e72715d086297217d9cc3caabb81b6dcf3deb1c0c798f3a890f7a33",
7
7
  "rustc": "rustc 1.98.0 (88d9e12ae 2026-08-18)",
8
8
  "glibcFloor": "GLIBC_2.34",
9
9
  "neededSonames": [
@@ -1,6 +1,6 @@
1
1
  use crate::card_view::CardView;
2
2
  use crate::protocol::{Card, ViewMessage, demo_view, emit, emit_error, emit_ready};
3
- use crate::runtime::{duplicate_labels, install_css, now_ms, start_input_reader};
3
+ use crate::runtime::{apply_theme, duplicate_labels, install_css, now_ms, start_input_reader};
4
4
  use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
5
5
  use relm4::gtk;
6
6
  use relm4::gtk::glib;
@@ -11,7 +11,10 @@ use std::collections::{HashMap, HashSet};
11
11
  use std::time::Duration;
12
12
 
13
13
  const COMPACT_HEIGHT: i32 = 84;
14
- const EXPANDED_HEIGHT: i32 = 252;
14
+ /// The ribbon sits on the same rhythm as tiled windows, so it reads as one of them rather than as
15
+ /// a bar bolted to the top edge. Matches `gaps` in the compositor's layout.
16
+ const OUTER_MARGIN: i32 = 8;
17
+ const EXPANDED_HEIGHT: i32 = 276;
15
18
  const ORDER_REFRESH_MS: i64 = 15_000;
16
19
 
17
20
  pub struct AppInit {
@@ -38,6 +41,7 @@ pub struct App {
38
41
  #[derive(Debug)]
39
42
  pub enum AppMsg {
40
43
  View(ViewMessage),
44
+ Theme(String),
41
45
  Tick,
42
46
  Hover(String, bool),
43
47
  Focus(String, bool),
@@ -69,36 +73,27 @@ impl Component for App {
69
73
 
70
74
  #[name = "body"]
71
75
  gtk::Box {
76
+ add_css_class: "band",
72
77
  set_orientation: gtk::Orientation::Horizontal,
73
- set_spacing: 8,
74
- set_margin_top: 4,
75
- set_margin_bottom: 6,
76
- set_margin_start: 10,
77
- set_margin_end: 10,
78
+ set_spacing: 0,
78
79
 
79
80
  #[name = "brand"]
80
81
  gtk::Box {
81
82
  add_css_class: "brand",
82
83
  set_orientation: gtk::Orientation::Vertical,
83
- set_width_request: 146,
84
- set_vexpand: true,
84
+ set_width_request: 132,
85
+ set_valign: gtk::Align::Center,
85
86
 
86
87
  gtk::Label {
87
88
  add_css_class: "brand-eyebrow",
88
- set_label: "π TELEMETRY RIBBON",
89
+ set_label: "π ACTIVITY",
89
90
  set_halign: gtk::Align::Start,
90
91
  },
91
92
  gtk::Label {
92
93
  add_css_class: "brand-title",
93
- set_label: "Activity",
94
- set_halign: gtk::Align::Start,
95
- },
96
- gtk::Label {
97
- add_css_class: "brand-subtitle",
98
- set_label: "Calm order · live detail",
94
+ set_label: "Sessions",
99
95
  set_halign: gtk::Align::Start,
100
96
  },
101
- gtk::Box { set_vexpand: true },
102
97
  #[name = "meta"]
103
98
  gtk::Label {
104
99
  add_css_class: "meta",
@@ -118,7 +113,9 @@ impl Component for App {
118
113
  set_orientation: gtk::Orientation::Horizontal,
119
114
  set_spacing: 8,
120
115
  set_halign: gtk::Align::Start,
121
- set_valign: gtk::Align::Start,
116
+ set_valign: gtk::Align::Center,
117
+ set_margin_top: 8,
118
+ set_margin_bottom: 8,
122
119
  }
123
120
  }
124
121
  }
@@ -141,8 +138,9 @@ impl Component for App {
141
138
  root.set_anchor(Edge::Top, true);
142
139
  root.set_anchor(Edge::Left, true);
143
140
  root.set_anchor(Edge::Right, true);
144
- root.set_margin(Edge::Left, 8);
145
- root.set_margin(Edge::Right, 8);
141
+ root.set_margin(Edge::Top, OUTER_MARGIN);
142
+ root.set_margin(Edge::Left, OUTER_MARGIN);
143
+ root.set_margin(Edge::Right, OUTER_MARGIN);
146
144
  root.set_exclusive_zone(COMPACT_HEIGHT);
147
145
  root.set_keyboard_mode(KeyboardMode::None);
148
146
  if init.click_through {
@@ -202,6 +200,7 @@ impl Component for App {
202
200
  ) {
203
201
  match message {
204
202
  AppMsg::View(view) => self.apply_view(widgets, root, &sender, view),
203
+ AppMsg::Theme(definitions) => apply_theme(&definitions),
205
204
  AppMsg::Tick => {
206
205
  if now_ms() >= self.next_order_refresh_at {
207
206
  self.regroup();
@@ -407,6 +406,7 @@ impl App {
407
406
  self.end_engagement();
408
407
  root.set_keyboard_mode(KeyboardMode::None);
409
408
  self.apply_expansion(widgets, root, None);
409
+ root.set_exclusive_zone(-1);
410
410
  root.set_visible(false);
411
411
  }
412
412
  emit(