@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 +37 -6
- package/bin/pi-activity-strip.mjs +55 -5
- package/examples/simulate-sessions.mjs +5 -0
- package/extensions/activity-strip.js +6 -0
- package/extensions/activity-strip.ts +6 -0
- package/package.json +4 -4
- package/src/broker/server.mjs +23 -2
- package/src/broker/session-store.mjs +6 -0
- package/src/client/broker-client.mjs +6 -0
- package/src/client/launcher.mjs +6 -0
- package/src/client/session-telemetry.mjs +11 -2
- package/src/common/activity-order.mjs +68 -0
- package/src/common/alignment-controller.mjs +74 -0
- package/src/common/compatibility.mjs +14 -2
- package/src/common/constants.mjs +10 -0
- package/src/common/contracts.d.ts +10 -0
- package/src/common/contracts.ts +10 -0
- package/src/common/electron.mjs +6 -0
- package/src/common/format.mjs +6 -0
- package/src/common/niri-focus.mjs +322 -0
- package/src/common/protocol.mjs +6 -0
- package/src/common/status-report.mjs +6 -0
- package/src/common/telemetry.mjs +13 -3
- package/src/electron/main.mjs +307 -127
- package/src/electron/niri-native-window-runtime.mjs +178 -0
- package/src/electron/niri-workspace-events.mjs +81 -0
- package/src/electron/preload.cjs +40 -0
- package/src/electron/renderer-visibility-runtime.mjs +107 -0
- package/src/electron/workspace-view-runtime.mjs +360 -0
- package/src/ui/strip-html.mjs +351 -242
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
|
|
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
|
|
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=
|
|
183
|
-
-
|
|
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> [
|
|
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
|
|
43
|
-
if (!stripWindow
|
|
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
|
|
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";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryinget/pi-activity-strip",
|
|
3
|
-
"version": "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": "
|
|
80
|
-
"@earendil-works/pi-coding-agent": "
|
|
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": "
|
|
83
|
+
"typebox": "1.3.7",
|
|
84
84
|
"typescript": "5.9.2"
|
|
85
85
|
},
|
|
86
86
|
"overrides": {
|
package/src/broker/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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 */
|
package/src/client/launcher.mjs
CHANGED
|
@@ -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
|
-
|
|
111
|
+
...(exactSessionId ? { sessionId: exactSessionId } : {}),
|
|
112
|
+
cwd: nextCwd,
|
|
104
113
|
sessionName: pi?.getSessionName?.() ?? snapshot.sessionName,
|
|
105
|
-
detail: previewPath(
|
|
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
|
|
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
|
|
143
|
+
`Click-through mode: ${report.clickThroughDefault ? "enabled by environment" : "disabled (interactive default)"}`,
|
|
132
144
|
];
|
|
133
145
|
|
|
134
146
|
if (typeof report.displayCount === "number") {
|
package/src/common/constants.mjs
CHANGED
|
@@ -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 {
|
package/src/common/contracts.ts
CHANGED
|
@@ -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 {
|
package/src/common/electron.mjs
CHANGED
|
@@ -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";
|
package/src/common/format.mjs
CHANGED
|
@@ -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.
|