@tryinget/pi-activity-strip 0.2.0 → 0.3.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 CHANGED
@@ -24,7 +24,7 @@ This package is designed for the exact workflow you asked for:
24
24
 
25
25
  - auto-starts a local top-row overlay when Pi starts in a TUI session
26
26
  - tracks each active Pi session independently
27
- - shows one card per live session
27
+ - on Niri, shows one card per tracked live Pi terminal on only the focused workspace; non-Niri desktops retain the global live-session view
28
28
  - surfaces:
29
29
  - repo/session label
30
30
  - current phase
@@ -33,6 +33,11 @@ This package is designed for the exact workflow you asked for:
33
33
  - elapsed time plus last-seen freshness
34
34
  - state color (`thinking`, `tool`, `waiting`, `done`, `error`)
35
35
  - keeps a local broker so multiple Pi processes can report into one strip
36
+ - marks the Pi session in the currently focused Niri/Ghostty terminal with a stronger border and left rail, without adding another label
37
+ - keeps green `done`/`monitoring` cards directly beside the Activity tile, followed by active work and then other settled sessions, on a calm 15-second ordering clock
38
+ - reveals prompt, response, path, and full activity detail on hover or keyboard focus
39
+ - focuses the exact matching Ghostty/Niri window on click or Enter, failing closed when identity is missing or ambiguous
40
+ - keeps an aligned strip resident on its Niri workspace while that workspace still has tracked terminals, so visiting an empty workspace does not unmap or reposition it
36
41
 
37
42
  ## Architecture
38
43
 
@@ -51,14 +56,16 @@ It does not require moving your workflow onto `pi-server` first.
51
56
  Implemented now:
52
57
  - local per-host broker
53
58
  - primary-display top-row strip
54
- - one card per active Pi session
59
+ - one card per tracked live Pi terminal on the focused Niri workspace, regardless of activity state
55
60
  - headless-safe telemetry publishing
56
- - explicit open/status/doctor/snapshot/fix-top/stop commands
61
+ - explicit open/focus-strip/focus-session/status/doctor/snapshot/fix-top/stop commands
62
+ - focus-scoped Left/Right navigation and Shift+Left/Right manual card movement
57
63
  - local visual capture helpers so the agent can inspect the strip directly
58
64
 
59
65
  Not implemented yet:
60
66
  - multi-monitor strip replication
61
- - historical timeline / expand-on-hover detail
67
+ - historical timeline
68
+ - persisted manual card order across strip restarts
62
69
  - remote observers via `pi-server`
63
70
 
64
71
  ## Installation in Pi
@@ -93,6 +100,8 @@ or directly:
93
100
 
94
101
  ```bash
95
102
  node ./bin/pi-activity-strip.mjs open
103
+ node ./bin/pi-activity-strip.mjs focus-strip
104
+ node ./bin/pi-activity-strip.mjs focus-session <full-pi-session-id>
96
105
  node ./bin/pi-activity-strip.mjs status
97
106
  node ./bin/pi-activity-strip.mjs doctor
98
107
  node ./bin/pi-activity-strip.mjs snapshot
@@ -116,6 +125,27 @@ In Pi with UI support:
116
125
  - `/activity-strip status` opens a detailed runtime status report when an editor surface is available
117
126
  - `/activity-strip doctor` opens the host-compatibility report
118
127
 
128
+ ## Interaction model
129
+
130
+ - **Workspace locality:** one strip follows the Niri workspace selected with Up/Down and renders only tracked Pi terminals whose exact Ghostty windows are on that workspace. Focused-workspace events trigger reconciliation immediately, with polling retained as a fallback. When an empty workspace is visited, an aligned strip whose resident workspace still has tracked terminals remains rendered on that prior workspace; Niri keeps it off the empty workspace and returning brings the already-positioned strip back with its row. If its resident terminals disappear, the renderer is concealed and input-disabled. When an actual remap or floating correction is unavoidable, reveal waits beyond Niri's compositor movement animation and then re-verifies placement and membership. The broker remains global and non-Niri desktops retain the global card view.
131
+ - **Ordering:** green `done` cards whose footer reads `monitoring` stay at the far left beside the Activity tile. Active tool/thinking/waiting cards follow, then other settled cards. The group order refreshes every 15 seconds rather than on every telemetry packet; text and timers still update live.
132
+ - **Current terminal:** on Niri, the card matching the focused Ghostty window gets a stronger border and left rail without an extra label. Matching uses the same exact session-title identity seam as click-to-focus and fails closed when focus or identity is missing or ambiguous.
133
+ - **Pointer:** hover expands the strip and reveals detail, last prompt, assistant preview, and path. Leaving the strip or activating another window collapses it immediately. Single click asks Niri to focus the one Ghostty title carrying that exact Pi session-id suffix.
134
+ - **Keyboard inside the strip:** Left/Right changes card focus, Enter activates the focused card, and Shift+Left/Right manually moves it. Manual movement lasts until a later activity regroup or runtime restart.
135
+ - **Fail-closed focus:** current telemetry uses the exact Pi identity directly. For already-running tabs that still publish a legacy broker id, the strip may recover only the process-bound `pi-session-presence` sidecar when its source, PID, and cwd all agree; this is not repo-name guessing or arbitrary-PID focusing. If identity still matches zero or multiple Ghostty windows, focus does nothing and asks for one `/reload`.
136
+
137
+ ### Keyboard-only entry on Niri
138
+
139
+ The package deliberately does not reserve a global Electron shortcut. Bind one compositor key to the fail-closed CLI entrypoint instead:
140
+
141
+ ```kdl
142
+ binds {
143
+ Mod+Shift+A { spawn "node" "/home/tryinget/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip/bin/pi-activity-strip.mjs" "focus-strip"; }
144
+ }
145
+ ```
146
+
147
+ `focus-strip` gives keyboard focus only to the unique strip already resident on the currently focused workspace; it never moves a strip between workspaces. When the focused workspace has no tracked live Pi terminals, the command fails closed rather than forcing an empty bar into view. This keeps shortcut ownership explicit in Niri and avoids application-level global-key collisions.
148
+
119
149
  ## Verification commands
120
150
 
121
151
  ### Package checks
@@ -179,8 +209,8 @@ Use `doctor` before opening the strip when the host/display assumptions are unce
179
209
 
180
210
  - `PI_ACTIVITY_STRIP_AUTO_START=0`
181
211
  - disable automatic strip opening on Pi session start
182
- - `PI_ACTIVITY_STRIP_CLICK_THROUGH=0`
183
- - make the overlay clickable instead of mouse-transparent
212
+ - `PI_ACTIVITY_STRIP_CLICK_THROUGH=1`
213
+ - opt out of interaction and restore a mouse-transparent overlay; interactive hover/click/keyboard behavior is the default
184
214
  - `PI_ACTIVITY_STRIP_ELECTRON_BIN=/path/to/electron`
185
215
  - override Electron binary discovery
186
216
  - `GLIMPSE_ELECTRON_BIN=/path/to/electron`
@@ -194,6 +224,7 @@ If you want this for all current tabs:
194
224
  2. run `/reload` inside each already-open Pi tab
195
225
  3. open the strip once with `/activity-strip` or `npm run strip:open`
196
226
  4. from then on, every loaded Pi session should report into the same top-row ribbon
227
+ 5. ensure `pi-little-helpers` session presence is loaded when you want exact click-to-Ghostty focus; its `· <full-32-hex-session-id-token>` title suffix is the preferred fail-closed identity seam (an 8-hex legacy title remains usable only when no legacy duplicate or migrated full title shares its prefix)
197
228
 
198
229
  ## References
199
230
 
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ // ---
3
+ // summary: "command-line entrypoint for opening, inspecting, repairing, and stopping the activity strip runtime"
4
+ // read_when:
5
+ // - "operating or diagnosing the activity strip from a terminal"
6
+ // ---
7
+
2
8
  import { execFile, spawn } from "node:child_process";
3
9
  import path from "node:path";
4
10
  import { setTimeout as delay } from "node:timers/promises";
@@ -16,6 +22,7 @@ import {
16
22
  } from "../src/common/compatibility.mjs";
17
23
  import { ACTIVITY_STRIP_START_TIMEOUT_MS } from "../src/common/constants.mjs";
18
24
  import { locateElectron } from "../src/common/electron.mjs";
25
+ import { focusNiriStrip, resolveActivityStripWindow } from "../src/common/niri-focus.mjs";
19
26
  import { makeMessage } from "../src/common/protocol.mjs";
20
27
  import { formatBrokerRuntimeStatus } from "../src/common/status-report.mjs";
21
28
 
@@ -26,7 +33,7 @@ const execFileAsync = promisify(execFile);
26
33
 
27
34
  function usage() {
28
35
  console.log(
29
- `Usage: pi-activity-strip <open|status|doctor|snapshot|fix-top|stop|serve> [--json]\n\nCommands:\n open Start the top-row activity strip if it is not already running\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 Move the strip window flush to the top edge in Niri\n stop Ask the running strip to shut down\n serve Internal helper; starts the Electron shell in the foreground\n`,
36
+ `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 Move the strip window flush to the top edge in Niri\n stop Ask the running strip to shut down\n serve Internal helper; starts the Electron shell in the foreground\n`,
30
37
  );
31
38
  }
32
39
 
@@ -39,12 +46,15 @@ async function moveStripToTop() {
39
46
  throw new Error("Unexpected niri windows payload");
40
47
  }
41
48
 
42
- const stripWindow = windows.find((window) => window?.title === "Pi Activity Strip");
43
- if (!stripWindow?.id) {
44
- throw new Error("Could not find Pi Activity Strip window in niri");
49
+ const stripWindow = resolveActivityStripWindow(windows);
50
+ if (!stripWindow) {
51
+ throw new Error("Could not find one unique Pi Activity Strip window in niri");
45
52
  }
46
53
 
47
- const currentY = Number(stripWindow.layout?.tile_pos_in_workspace_view?.[1] ?? 0);
54
+ const layout = /** @type {{ tile_pos_in_workspace_view?: unknown[] }} */ (
55
+ stripWindow.layout ?? {}
56
+ );
57
+ const currentY = Number(layout.tile_pos_in_workspace_view?.[1] ?? 0);
48
58
  if (Math.abs(currentY) < 1) {
49
59
  return 0;
50
60
  }
@@ -137,6 +147,9 @@ async function openStrip({ detached = true } = {}) {
137
147
  async function main() {
138
148
  const command = process.argv[2] ?? "open";
139
149
  const jsonOutput = process.argv.includes("--json");
150
+ if (process.argv.includes("--click-through")) {
151
+ process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH = "1";
152
+ }
140
153
 
141
154
  switch (command) {
142
155
  case "open":
@@ -145,6 +158,43 @@ async function main() {
145
158
  case "serve":
146
159
  process.exitCode = await openStrip({ detached: false });
147
160
  return;
161
+ case "focus-strip": {
162
+ try {
163
+ const status = await getBrokerStatus({ expectReply: true });
164
+ if (status?.runtimeStatus?.windowVisible !== true) {
165
+ console.error("No visible strip exists on the focused workspace; focus did nothing.");
166
+ process.exitCode = 1;
167
+ return;
168
+ }
169
+ const result = await focusNiriStrip(execFileAsync, process.env, status?.snapshot?.sessions);
170
+ if (!result.ok) console.error(result.error || "Strip focus did nothing.");
171
+ process.exitCode = result.ok ? 0 : 1;
172
+ } catch {
173
+ console.error("Activity strip is not running; focus did nothing.");
174
+ process.exitCode = 1;
175
+ }
176
+ return;
177
+ }
178
+ case "focus":
179
+ case "focus-session": {
180
+ const sessionId = String(process.argv[3] ?? "").trim();
181
+ if (!sessionId) {
182
+ console.error("focus-session requires the full Pi session id");
183
+ process.exitCode = 2;
184
+ return;
185
+ }
186
+ try {
187
+ const result = await sendBrokerMessage(makeMessage("focus", { sessionId }), {
188
+ expectReply: true,
189
+ });
190
+ if (!result?.ok) console.error(result?.error || "Focus did nothing.");
191
+ process.exitCode = result?.ok ? 0 : 1;
192
+ } catch {
193
+ console.error("Activity strip is not running; focus did nothing.");
194
+ process.exitCode = 1;
195
+ }
196
+ return;
197
+ }
148
198
  case "status": {
149
199
  try {
150
200
  const result = await getBrokerStatus({ expectReply: true });
@@ -1,3 +1,8 @@
1
+ /**
2
+ summary: "Publishes a timed sequence of simulated activity-strip session snapshots, updates, and removals."
3
+ read_when:
4
+ - "Changing the example session labels, lifecycle transitions, timing, or broker publication flow."
5
+ */
1
6
  import { setTimeout as delay } from "node:timers/promises";
2
7
  import { publishSessionSnapshot, removeSession } from "../src/client/broker-client.mjs";
3
8
  import { createInitialSnapshot } from "../src/common/telemetry.mjs";
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "registers activity-strip commands and forwards Pi lifecycle telemetry to the local broker"
3
+ // read_when:
4
+ // - "changing extension commands, autostart behavior, or telemetry event wiring"
5
+ // ---
6
+
1
7
  import { execFile } from "node:child_process";
2
8
  import path from "node:path";
3
9
  import { fileURLToPath } from "node:url";
@@ -1 +1,7 @@
1
+ // ---
2
+ // summary: "typed extension entrypoint that re-exports the JavaScript activity-strip implementation"
3
+ // read_when:
4
+ // - "resolving the TypeScript-facing extension module boundary"
5
+ // ---
6
+
1
7
  export { default } from "./activity-strip.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryinget/pi-activity-strip",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "pi extension package for activity-strip workflows in monorepo runtime",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -76,11 +76,11 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@biomejs/biome": "2.4.4",
79
- "@earendil-works/pi-ai": "^0.80.6",
80
- "@earendil-works/pi-coding-agent": "^0.80.6",
79
+ "@earendil-works/pi-ai": "0.83.0",
80
+ "@earendil-works/pi-coding-agent": "0.83.0",
81
81
  "@types/node": "22.14.0",
82
82
  "@typescript/native-preview": "7.0.0-dev.20260417.1",
83
- "typebox": "^1.0.0",
83
+ "typebox": "1.3.7",
84
84
  "typescript": "5.9.2"
85
85
  },
86
86
  "overrides": {
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "hosts the Unix-socket broker that accepts session updates and broadcasts activity snapshots"
3
+ // read_when:
4
+ // - "changing broker lifecycle, socket message handling, or snapshot emission"
5
+ // ---
6
+
1
7
  import { EventEmitter } from "node:events";
2
8
  import fs from "node:fs";
3
9
  import net from "node:net";
@@ -41,19 +47,27 @@ export class ActivityStripBroker extends EventEmitter {
41
47
  this.socketDir = options.socketDir ?? ACTIVITY_STRIP_SOCKET_DIR;
42
48
  this.store = options.store ?? new SessionStore();
43
49
  this.getRuntimeStatus = options.getRuntimeStatus ?? (() => undefined);
50
+ this.focusSession =
51
+ options.focusSession ?? (async () => ({ ok: false, error: "Focus unavailable" }));
44
52
  this.server = net.createServer((socket) => this.handleConnection(socket));
45
53
  this.tick = null;
46
54
  }
47
55
 
48
56
  async start() {
49
- fs.mkdirSync(this.socketDir, { recursive: true });
57
+ fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
58
+ fs.chmodSync(this.socketDir, 0o700);
50
59
  safeUnlink(this.socketPath);
51
60
 
52
61
  await new Promise((resolve, reject) => {
53
62
  this.server.once("error", reject);
54
63
  this.server.listen(this.socketPath, () => {
55
64
  this.server.off("error", reject);
56
- resolve(undefined);
65
+ try {
66
+ fs.chmodSync(this.socketPath, 0o600);
67
+ resolve(undefined);
68
+ } catch (error) {
69
+ reject(error);
70
+ }
57
71
  });
58
72
  });
59
73
 
@@ -124,6 +138,13 @@ export class ActivityStripBroker extends EventEmitter {
124
138
  runtimeStatus: this.getRuntimeStatus(),
125
139
  });
126
140
  return;
141
+ case "focus":
142
+ Promise.resolve(this.focusSession(String(message.sessionId ?? "")))
143
+ .then((result) => this.reply(socket, { type: "focus", ...result }))
144
+ .catch(() =>
145
+ this.reply(socket, { ok: false, type: "focus", error: "Focus failed closed." }),
146
+ );
147
+ return;
127
148
  case "shutdown":
128
149
  this.reply(socket, { ok: true, type: "shutdown" });
129
150
  setTimeout(() => {
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "stores normalized session snapshots, expires stale entries, and returns display-ready ordering"
3
+ // read_when:
4
+ // - "changing session retention, upsert semantics, or broker snapshot ordering"
5
+ // ---
6
+
1
7
  import { ACTIVITY_STRIP_STALE_AFTER_MS } from "../common/constants.mjs";
2
8
  import { normalizeSessionSnapshot, sortSessions } from "../common/protocol.mjs";
3
9
 
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "sends newline-delimited broker requests for status, shutdown, session publication, and removal"
3
+ // read_when:
4
+ // - "changing client socket transport, reply timeouts, or broker request helpers"
5
+ // ---
6
+
1
7
  import net from "node:net";
2
8
  /** @typedef {import("../common/contracts.ts").BrokerClientOptions} BrokerClientOptions */
3
9
  /** @typedef {import("../common/contracts.ts").BrokerResponse} BrokerResponse */
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "ensures a compatible activity-strip process is launched and waits for overlay readiness"
3
+ // read_when:
4
+ // - "changing autostart compatibility gates, process spawning, or readiness polling"
5
+ // ---
6
+
1
7
  import { spawn } from "node:child_process";
2
8
  import { setTimeout as delay } from "node:timers/promises";
3
9
  import { assessActivityStripCompatibility } from "../common/compatibility.mjs";
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "converts Pi lifecycle and tool events into throttled session snapshots for the broker"
3
+ // read_when:
4
+ // - "changing session state transitions, heartbeat delivery, or event-derived telemetry"
5
+ // ---
6
+
1
7
  /** @typedef {import("../common/contracts.ts").SessionSnapshot} SessionSnapshot */
2
8
  /** @typedef {import("../common/contracts.ts").SessionTelemetryOptions} SessionTelemetryOptions */
3
9
  /** @typedef {import("../common/contracts.ts").SessionStartContextLike} SessionStartContextLike */
@@ -99,10 +105,13 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
99
105
  },
100
106
  /** @param {SessionStartContextLike} ctx */
101
107
  async onSessionStart(ctx) {
108
+ const exactSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "").trim();
109
+ const nextCwd = ctx?.cwd ?? snapshot.cwd;
102
110
  update({
103
- cwd: ctx?.cwd ?? snapshot.cwd,
111
+ ...(exactSessionId ? { sessionId: exactSessionId } : {}),
112
+ cwd: nextCwd,
104
113
  sessionName: pi?.getSessionName?.() ?? snapshot.sessionName,
105
- detail: previewPath(ctx?.cwd ?? snapshot.cwd, 72) || "Ready",
114
+ detail: previewPath(nextCwd, 72) || "Ready",
106
115
  });
107
116
  startHeartbeat();
108
117
  await flush();
@@ -0,0 +1,68 @@
1
+ // ---
2
+ // summary: "provides deterministic monitoring-first strip ordering while preserving operator-defined order"
3
+ // read_when:
4
+ // - "changing activity grouping, calm-cadence reconciliation, or manual card order"
5
+ // ---
6
+
7
+ const ACTIVE_STATES = new Set(["thinking", "tool", "waiting"]);
8
+
9
+ /** @param {{ state?: string; agentActive?: boolean }} session */
10
+ export function isActiveSession(session) {
11
+ return Boolean(session?.agentActive || ACTIVE_STATES.has(String(session?.state ?? "")));
12
+ }
13
+
14
+ /** @param {{ state?: string; toolName?: string; toolTarget?: string }} session */
15
+ export function isMonitoringSession(session) {
16
+ return Boolean(
17
+ session?.state === "success" &&
18
+ !String(session?.toolName ?? "").trim() &&
19
+ !String(session?.toolTarget ?? "").trim(),
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Reconcile at the calm clock: green settled/monitoring cards first, then active cards,
25
+ * then other inactive cards. Preserve previous/manual order inside each group and append
26
+ * new cards deterministically.
27
+ * @param {Array<{ sessionId: string; state?: string; agentActive?: boolean; toolName?: string; toolTarget?: string; updatedAt?: number }>} sessions
28
+ * @param {string[]} previousOrder
29
+ * @param {{ regroup?: boolean }} [options]
30
+ */
31
+ export function reconcileActivityOrder(sessions, previousOrder = [], { regroup = true } = {}) {
32
+ const byId = new Map(sessions.map((session) => [session.sessionId, session]));
33
+ const surviving = previousOrder.filter((id) => byId.has(id));
34
+ const known = new Set(surviving);
35
+ for (const session of sessions) {
36
+ if (!session.sessionId || known.has(session.sessionId)) continue;
37
+ surviving.push(session.sessionId);
38
+ known.add(session.sessionId);
39
+ }
40
+ if (!regroup) return surviving;
41
+
42
+ const priorIndex = new Map(surviving.map((id, index) => [id, index]));
43
+ const ordered = [...sessions].sort((left, right) => {
44
+ const leftPriority = isMonitoringSession(left) ? 2 : isActiveSession(left) ? 1 : 0;
45
+ const rightPriority = isMonitoringSession(right) ? 2 : isActiveSession(right) ? 1 : 0;
46
+ const groupDelta = rightPriority - leftPriority;
47
+ if (groupDelta) return groupDelta;
48
+ const leftPrior = priorIndex.get(left.sessionId);
49
+ const rightPrior = priorIndex.get(right.sessionId);
50
+ if (leftPrior != null || rightPrior != null) {
51
+ return (leftPrior ?? Number.MAX_SAFE_INTEGER) - (rightPrior ?? Number.MAX_SAFE_INTEGER);
52
+ }
53
+ const updateDelta = Number(right.updatedAt ?? 0) - Number(left.updatedAt ?? 0);
54
+ if (updateDelta) return updateDelta;
55
+ return left.sessionId.localeCompare(right.sessionId);
56
+ });
57
+ return ordered.map((session) => session.sessionId).filter((id) => byId.has(id));
58
+ }
59
+
60
+ /** @param {string[]} order @param {string} sessionId @param {-1 | 1} delta */
61
+ export function moveOrderItem(order, sessionId, delta) {
62
+ const next = [...order];
63
+ const from = next.indexOf(sessionId);
64
+ const to = from + delta;
65
+ if (from < 0 || to < 0 || to >= next.length) return next;
66
+ [next[from], next[to]] = [next[to], next[from]];
67
+ return next;
68
+ }
@@ -0,0 +1,74 @@
1
+ // ---
2
+ // summary: "coalesces native-window alignment so stale async work cannot overwrite newer geometry"
3
+ // read_when:
4
+ // - "changing Electron/Niri geometry reconciliation or testing rapid expansion transitions"
5
+ // ---
6
+
7
+ /**
8
+ * @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown } } | null | undefined} window
9
+ */
10
+ export function hasNiriFloatingPosition(window) {
11
+ const position = window?.layout?.tile_pos_in_workspace_view;
12
+ return window?.is_floating === true && Array.isArray(position) && position.length >= 2;
13
+ }
14
+
15
+ /**
16
+ * @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown, window_size?: unknown } } | null | undefined} window
17
+ * @param {{x: number, y: number, width: number, height: number}} target
18
+ */
19
+ export function isNiriWindowAligned(window, target) {
20
+ if (!hasNiriFloatingPosition(window)) return false;
21
+ const position = window?.layout?.tile_pos_in_workspace_view;
22
+ const size = window?.layout?.window_size;
23
+ return Boolean(
24
+ Array.isArray(position) &&
25
+ Array.isArray(size) &&
26
+ Number(size[0]) === target.width &&
27
+ Number(size[1]) === target.height &&
28
+ Math.abs(Number(position[0]) - target.x) < 1 &&
29
+ Math.abs(Number(position[1]) - target.y) < 1,
30
+ );
31
+ }
32
+
33
+ /**
34
+ * @param {(attempt: { generation: number, isCurrent: () => boolean }) => Promise<void>} run
35
+ */
36
+ export function createLatestOnlyRunner(run) {
37
+ if (typeof run !== "function") throw new TypeError("run must be a function");
38
+
39
+ let requestedGeneration = 0;
40
+ /** @type {Promise<void> | null} */
41
+ let worker = null;
42
+
43
+ async function drain() {
44
+ while (true) {
45
+ const generation = requestedGeneration;
46
+ const isCurrent = () => generation === requestedGeneration;
47
+ try {
48
+ await run({ generation, isCurrent });
49
+ } catch {
50
+ // Alignment is best effort. A newer request must still be allowed to reconcile the window.
51
+ }
52
+ if (isCurrent()) return;
53
+ }
54
+ }
55
+
56
+ function request() {
57
+ requestedGeneration += 1;
58
+ if (!worker) {
59
+ worker = drain().finally(() => {
60
+ worker = null;
61
+ });
62
+ }
63
+ return requestedGeneration;
64
+ }
65
+
66
+ async function waitForIdle() {
67
+ await worker;
68
+ }
69
+
70
+ return {
71
+ request,
72
+ waitForIdle,
73
+ };
74
+ }
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "detects desktop and Electron prerequisites and formats actionable activity-strip compatibility reports"
3
+ // read_when:
4
+ // - "changing host detection, compatibility blockers, warnings, or doctor output"
5
+ // ---
6
+
1
7
  import { execFile } from "node:child_process";
2
8
  import { promisify } from "node:util";
3
9
  import { locateElectron } from "./electron.mjs";
@@ -85,6 +91,12 @@ export async function assessActivityStripCompatibility(options = {}) {
85
91
  );
86
92
  }
87
93
 
94
+ if (windowManager === "niri" && displayCount === 0) {
95
+ blockers.push(
96
+ "Niri reports no connected display outputs. Turn on or reconnect the monitor before opening the activity strip.",
97
+ );
98
+ }
99
+
88
100
  try {
89
101
  electronPath = await locateElectronImpl();
90
102
  } catch (error) {
@@ -111,7 +123,7 @@ export async function assessActivityStripCompatibility(options = {}) {
111
123
  displayCount,
112
124
  alignmentMode: windowManager === "niri" ? "niri" : "generic",
113
125
  primaryDisplayOnly: true,
114
- clickThroughDefault: env.PI_ACTIVITY_STRIP_CLICK_THROUGH !== "0",
126
+ clickThroughDefault: env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
115
127
  blockers,
116
128
  warnings,
117
129
  };
@@ -128,7 +140,7 @@ export function formatCompatibilityReport(report) {
128
140
  `Electron: ${report.electronPath || "not found"}`,
129
141
  `Alignment mode: ${report.alignmentMode}`,
130
142
  `Primary-display only: ${report.primaryDisplayOnly ? "yes" : "no"}`,
131
- `Click-through default: ${report.clickThroughDefault ? "yes" : "no"}`,
143
+ `Click-through mode: ${report.clickThroughDefault ? "enabled by environment" : "disabled (interactive default)"}`,
132
144
  ];
133
145
 
134
146
  if (typeof report.displayCount === "number") {
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "defines shared socket paths, window dimensions, timing thresholds, and Electron binary candidates"
3
+ // read_when:
4
+ // - "tuning activity-strip runtime defaults or locating shared configuration values"
5
+ // ---
6
+
1
7
  import os from "node:os";
2
8
  import path from "node:path";
3
9
 
@@ -15,6 +21,10 @@ export const ACTIVITY_STRIP_SOCKET_PATH = path.join(
15
21
  );
16
22
  export const ACTIVITY_STRIP_WIDTH_PADDING = 16;
17
23
  export const ACTIVITY_STRIP_HEIGHT = 84;
24
+ export const ACTIVITY_STRIP_EXPANDED_HEIGHT = 252;
25
+ export const ACTIVITY_STRIP_ORDER_REFRESH_MS = 15_000;
26
+ export const ACTIVITY_STRIP_WORKSPACE_SYNC_MS = 1500;
27
+ export const ACTIVITY_STRIP_NIRI_ANIMATION_SETTLE_MS = 450;
18
28
  export const ACTIVITY_STRIP_HEARTBEAT_MS = 2500;
19
29
  export const ACTIVITY_STRIP_STALE_AFTER_MS = 12000;
20
30
  export const ACTIVITY_STRIP_BROADCAST_TICK_MS = 1000;
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "declares the public TypeScript shapes shared by activity-strip runtime and JavaScript consumers"
3
+ // read_when:
4
+ // - "checking emitted declaration contracts for sessions, broker messages, telemetry, or compatibility"
5
+ // ---
6
+
1
7
  export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success" | "error";
2
8
 
3
9
  export interface SessionSnapshot {
@@ -68,6 +74,7 @@ export interface ActivityStripBrokerOptions {
68
74
  remove(sessionId: string): boolean;
69
75
  };
70
76
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
77
+ focusSession?: (sessionId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
71
78
  }
72
79
 
73
80
  export interface ToolCallDescription {
@@ -95,6 +102,9 @@ export interface SessionStartContextLike {
95
102
  notify?: (message: string, level?: "info" | "warning" | "error") => void;
96
103
  editor?: (title: string, text: string) => Promise<unknown>;
97
104
  };
105
+ sessionManager?: {
106
+ getSessionId?: () => string;
107
+ };
98
108
  }
99
109
 
100
110
  export interface BeforeAgentStartEventLike {
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "defines source TypeScript contracts for session telemetry, broker transport, and runtime compatibility"
3
+ // read_when:
4
+ // - "changing shared activity-strip types or the interfaces consumed through JSDoc imports"
5
+ // ---
6
+
1
7
  export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success" | "error";
2
8
 
3
9
  export interface SessionSnapshot {
@@ -68,6 +74,7 @@ export interface ActivityStripBrokerOptions {
68
74
  remove(sessionId: string): boolean;
69
75
  };
70
76
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
77
+ focusSession?: (sessionId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
71
78
  }
72
79
 
73
80
  export interface ToolCallDescription {
@@ -95,6 +102,9 @@ export interface SessionStartContextLike {
95
102
  notify?: (message: string, level?: "info" | "warning" | "error") => void;
96
103
  editor?: (title: string, text: string) => Promise<unknown>;
97
104
  };
105
+ sessionManager?: {
106
+ getSessionId?: () => string;
107
+ };
98
108
  }
99
109
 
100
110
  export interface BeforeAgentStartEventLike {
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "resolves an executable Electron binary from explicit settings, local dependencies, or PATH candidates"
3
+ // read_when:
4
+ // - "changing Electron discovery order, environment overrides, or executable checks"
5
+ // ---
6
+
1
7
  import { constants } from "node:fs";
2
8
  import { access } from "node:fs/promises";
3
9
  import path from "node:path";
@@ -1,3 +1,9 @@
1
+ // ---
2
+ // summary: "provides ANSI-safe truncation, whitespace compaction, and concise path, command, and session labels"
3
+ // read_when:
4
+ // - "changing text previews or labels displayed in activity-strip telemetry"
5
+ // ---
6
+
1
7
  import path from "node:path";
2
8
 
3
9
  // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires an ESC control character pattern.