@tryinget/pi-activity-strip 0.2.0 → 0.4.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 +6 -1
- package/extensions/activity-strip.js +8 -2
- package/extensions/activity-strip.ts +6 -0
- package/package.json +9 -8
- package/src/broker/server.mjs +24 -3
- package/src/broker/session-store.mjs +24 -4
- package/src/client/broker-client.mjs +23 -3
- package/src/client/launcher.mjs +6 -0
- package/src/client/session-telemetry.mjs +87 -8
- package/src/common/activity-order.mjs +68 -0
- package/src/common/alignment-controller.mjs +74 -0
- package/src/common/card-display.mjs +48 -0
- package/src/common/compatibility.mjs +14 -2
- package/src/common/constants.mjs +12 -0
- package/src/common/contracts.d.ts +22 -1
- package/src/common/contracts.ts +22 -1
- 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 +9 -0
- package/src/common/status-report.mjs +6 -0
- package/src/common/telemetry.mjs +16 -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 +371 -246
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";
|
|
@@ -60,5 +65,5 @@ await publishSessionSnapshot(sessions[1]);
|
|
|
60
65
|
|
|
61
66
|
await delay(2000);
|
|
62
67
|
for (const session of sessions) {
|
|
63
|
-
await removeSession(session.sessionId);
|
|
68
|
+
await removeSession({ sessionId: session.sessionId, publisherId: session.publisherId });
|
|
64
69
|
}
|
|
@@ -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";
|
|
@@ -188,8 +194,8 @@ export default function activityStripExtension(pi) {
|
|
|
188
194
|
telemetry.onToolExecutionEnd(event);
|
|
189
195
|
});
|
|
190
196
|
|
|
191
|
-
pi.on("turn_end", async () => {
|
|
192
|
-
telemetry.onTurnEnd();
|
|
197
|
+
pi.on("turn_end", async (event) => {
|
|
198
|
+
telemetry.onTurnEnd(event);
|
|
193
199
|
});
|
|
194
200
|
|
|
195
201
|
pi.on("agent_settled", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryinget/pi-activity-strip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"quality:ci": "bash ./scripts/quality-gate.sh ci",
|
|
36
36
|
"check": "npm run quality:ci",
|
|
37
37
|
"test": "npm run quality:ci",
|
|
38
|
-
"docs:list": "
|
|
39
|
-
"docs:list:workspace": "
|
|
40
|
-
"docs:list:json": "
|
|
38
|
+
"docs:list": "node ~/ai-society/core/agent-scripts/scripts/docs-list.mjs",
|
|
39
|
+
"docs:list:workspace": "node ~/ai-society/core/agent-scripts/scripts/docs-list.mjs --workspace --discover",
|
|
40
|
+
"docs:list:json": "node ~/ai-society/core/agent-scripts/scripts/docs-list.mjs --json",
|
|
41
41
|
"release:check": "bash ./scripts/release-check.sh",
|
|
42
42
|
"release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh",
|
|
43
43
|
"strip:open": "node ./bin/pi-activity-strip.mjs open",
|
|
@@ -76,15 +76,16 @@
|
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@biomejs/biome": "2.4.4",
|
|
79
|
-
"@earendil-works/pi-
|
|
80
|
-
"@earendil-works/pi-
|
|
79
|
+
"@earendil-works/pi-agent-core": "0.84.3",
|
|
80
|
+
"@earendil-works/pi-ai": "0.84.3",
|
|
81
|
+
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
81
82
|
"@types/node": "22.14.0",
|
|
82
83
|
"@typescript/native-preview": "7.0.0-dev.20260417.1",
|
|
83
|
-
"typebox": "
|
|
84
|
+
"typebox": "1.3.7",
|
|
84
85
|
"typescript": "5.9.2"
|
|
85
86
|
},
|
|
86
87
|
"overrides": {
|
|
87
|
-
"fast-xml-parser": "5.
|
|
88
|
+
"fast-xml-parser": "5.7.0"
|
|
88
89
|
},
|
|
89
90
|
"peerDependencies": {
|
|
90
91
|
"@earendil-works/pi-ai": "*",
|
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(() => {
|
|
@@ -131,7 +152,7 @@ export class ActivityStripBroker extends EventEmitter {
|
|
|
131
152
|
}, 20);
|
|
132
153
|
return;
|
|
133
154
|
case "remove":
|
|
134
|
-
this.store.remove(message.sessionId);
|
|
155
|
+
this.store.remove(String(message.sessionId ?? ""), String(message.publisherId ?? ""));
|
|
135
156
|
this.emitSnapshot();
|
|
136
157
|
return;
|
|
137
158
|
case "upsert":
|
|
@@ -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
|
|
|
@@ -5,6 +11,16 @@ import { normalizeSessionSnapshot, sortSessions } from "../common/protocol.mjs";
|
|
|
5
11
|
/** @typedef {import("../common/contracts.ts").SessionStoreOptions} SessionStoreOptions */
|
|
6
12
|
/** @typedef {import("../common/contracts.ts").BrokerSnapshot} BrokerSnapshot */
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Stable store key. Sessions resumed into a second process keep publishing under
|
|
16
|
+
* their own publisherId so two live processes never fight over one card; legacy
|
|
17
|
+
* publishers without a publisherId keep the bare sessionId key.
|
|
18
|
+
* @param {SessionSnapshot} session
|
|
19
|
+
*/
|
|
20
|
+
function sessionKey(session) {
|
|
21
|
+
return session.publisherId ? `${session.sessionId}|${session.publisherId}` : session.sessionId;
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
export class SessionStore {
|
|
9
25
|
/** @param {SessionStoreOptions} [options] */
|
|
10
26
|
constructor(options = {}) {
|
|
@@ -17,13 +33,17 @@ export class SessionStore {
|
|
|
17
33
|
upsert(session) {
|
|
18
34
|
const normalized = normalizeSessionSnapshot(session);
|
|
19
35
|
if (!normalized.sessionId) return false;
|
|
20
|
-
this.sessions.set(normalized
|
|
36
|
+
this.sessions.set(sessionKey(normalized), normalized);
|
|
21
37
|
return true;
|
|
22
38
|
}
|
|
23
39
|
|
|
24
|
-
/** @param {string} sessionId */
|
|
25
|
-
remove(sessionId) {
|
|
26
|
-
|
|
40
|
+
/** @param {string} sessionId @param {string} [publisherId] */
|
|
41
|
+
remove(sessionId, publisherId) {
|
|
42
|
+
const id = String(sessionId ?? "");
|
|
43
|
+
if (!id) return false;
|
|
44
|
+
return publisherId
|
|
45
|
+
? this.sessions.delete(`${id}|${String(publisherId)}`)
|
|
46
|
+
: this.sessions.delete(id);
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
/** @param {number} [now] */
|
|
@@ -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 */
|
|
@@ -109,7 +115,21 @@ export async function publishSessionSnapshot(session, options = {}) {
|
|
|
109
115
|
await sendBrokerMessage(makeMessage("upsert", { session }), options);
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
/** @param {string}
|
|
113
|
-
export async function removeSession(
|
|
114
|
-
|
|
118
|
+
/** @param {{ sessionId: string; publisherId: string }} session @param {BrokerClientOptions} [options] */
|
|
119
|
+
export async function removeSession(session, options = {}) {
|
|
120
|
+
/** @type {Record<string, unknown>} */
|
|
121
|
+
let record = {};
|
|
122
|
+
let fallbackId = "";
|
|
123
|
+
if (session && typeof session === "object") {
|
|
124
|
+
record = session;
|
|
125
|
+
} else {
|
|
126
|
+
fallbackId = String(session ?? "");
|
|
127
|
+
}
|
|
128
|
+
await sendBrokerMessage(
|
|
129
|
+
makeMessage("remove", {
|
|
130
|
+
sessionId: String(record.sessionId ?? fallbackId),
|
|
131
|
+
publisherId: String(record.publisherId ?? ""),
|
|
132
|
+
}),
|
|
133
|
+
options,
|
|
134
|
+
);
|
|
115
135
|
}
|
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,4 +1,11 @@
|
|
|
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 */
|
|
8
|
+
/** @typedef {import("../common/contracts.ts").TurnEndEventLike} TurnEndEventLike */
|
|
2
9
|
/** @typedef {import("../common/contracts.ts").SessionTelemetryOptions} SessionTelemetryOptions */
|
|
3
10
|
/** @typedef {import("../common/contracts.ts").SessionStartContextLike} SessionStartContextLike */
|
|
4
11
|
/** @typedef {import("../common/contracts.ts").BeforeAgentStartEventLike} BeforeAgentStartEventLike */
|
|
@@ -6,6 +13,7 @@
|
|
|
6
13
|
/** @typedef {import("../common/contracts.ts").MessageUpdateEventLike} MessageUpdateEventLike */
|
|
7
14
|
/** @typedef {import("../common/contracts.ts").ToolExecutionEventLike} ToolExecutionEventLike */
|
|
8
15
|
import {
|
|
16
|
+
ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS,
|
|
9
17
|
ACTIVITY_STRIP_HEARTBEAT_MS,
|
|
10
18
|
ACTIVITY_STRIP_SEND_THROTTLE_MS,
|
|
11
19
|
} from "../common/constants.mjs";
|
|
@@ -35,7 +43,14 @@ function extractAssistantDelta(event) {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/** @param {SessionTelemetryOptions} [options] */
|
|
38
|
-
export function createSessionTelemetry({
|
|
46
|
+
export function createSessionTelemetry({
|
|
47
|
+
pi,
|
|
48
|
+
cwd = process.cwd(),
|
|
49
|
+
sessionName = "",
|
|
50
|
+
transport,
|
|
51
|
+
} = {}) {
|
|
52
|
+
const publish = transport?.publish ?? publishSessionSnapshot;
|
|
53
|
+
const removePublisher = transport?.remove ?? removeSession;
|
|
39
54
|
/** @type {SessionSnapshot} */
|
|
40
55
|
let snapshot = createInitialSnapshot({ cwd, sessionName });
|
|
41
56
|
/** @type {NodeJS.Timeout | null} */
|
|
@@ -44,20 +59,52 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
|
|
|
44
59
|
let flushTimer = null;
|
|
45
60
|
let disposed = false;
|
|
46
61
|
let pendingAssistant = "";
|
|
62
|
+
let lastStopReason = "";
|
|
63
|
+
let confirmedSignature = "";
|
|
64
|
+
let retryIndex = 0;
|
|
65
|
+
|
|
66
|
+
/** Signature of the display-relevant fields, used to detect lost transitions. */
|
|
67
|
+
function publishSignature() {
|
|
68
|
+
return [
|
|
69
|
+
snapshot.sessionId,
|
|
70
|
+
snapshot.state,
|
|
71
|
+
snapshot.phase,
|
|
72
|
+
snapshot.detail,
|
|
73
|
+
snapshot.agentActive,
|
|
74
|
+
snapshot.turnIndex,
|
|
75
|
+
snapshot.toolName,
|
|
76
|
+
snapshot.toolTarget,
|
|
77
|
+
snapshot.errorMessage,
|
|
78
|
+
].join("\u001f");
|
|
79
|
+
}
|
|
47
80
|
|
|
48
81
|
async function flush() {
|
|
49
82
|
flushTimer = null;
|
|
50
83
|
if (disposed) return;
|
|
84
|
+
// Heartbeat republish refreshes transport liveness only; lastEventAt stays
|
|
85
|
+
// pinned to the last real lifecycle event so stalled streams stay visible.
|
|
51
86
|
snapshot.updatedAt = now();
|
|
52
87
|
snapshot.repoLabel = formatRepoLabel(
|
|
53
88
|
snapshot.cwd,
|
|
54
89
|
pi?.getSessionName?.() ?? snapshot.sessionName,
|
|
55
90
|
);
|
|
56
91
|
snapshot.sessionName = compactWhitespace(pi?.getSessionName?.() ?? snapshot.sessionName);
|
|
92
|
+
const signature = publishSignature();
|
|
57
93
|
try {
|
|
58
|
-
await
|
|
94
|
+
await publish(snapshot);
|
|
95
|
+
confirmedSignature = signature;
|
|
96
|
+
retryIndex = 0;
|
|
59
97
|
} catch {
|
|
60
|
-
// Broker is optional at runtime; silent failure keeps pi stable.
|
|
98
|
+
// Broker is optional at runtime; silent failure keeps pi stable. Retry a
|
|
99
|
+
// bounded number of times when a state transition may not have landed so
|
|
100
|
+
// a transient connect failure cannot freeze the displayed state.
|
|
101
|
+
if (
|
|
102
|
+
signature !== confirmedSignature &&
|
|
103
|
+
retryIndex < ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS.length
|
|
104
|
+
) {
|
|
105
|
+
scheduleFlush(ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS[retryIndex]);
|
|
106
|
+
retryIndex += 1;
|
|
107
|
+
}
|
|
61
108
|
}
|
|
62
109
|
}
|
|
63
110
|
|
|
@@ -89,6 +136,7 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
|
|
|
89
136
|
...snapshot,
|
|
90
137
|
...partial,
|
|
91
138
|
updatedAt: now(),
|
|
139
|
+
lastEventAt: now(),
|
|
92
140
|
};
|
|
93
141
|
scheduleFlush();
|
|
94
142
|
}
|
|
@@ -99,10 +147,13 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
|
|
|
99
147
|
},
|
|
100
148
|
/** @param {SessionStartContextLike} ctx */
|
|
101
149
|
async onSessionStart(ctx) {
|
|
150
|
+
const exactSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "").trim();
|
|
151
|
+
const nextCwd = ctx?.cwd ?? snapshot.cwd;
|
|
102
152
|
update({
|
|
103
|
-
|
|
153
|
+
...(exactSessionId ? { sessionId: exactSessionId } : {}),
|
|
154
|
+
cwd: nextCwd,
|
|
104
155
|
sessionName: pi?.getSessionName?.() ?? snapshot.sessionName,
|
|
105
|
-
detail: previewPath(
|
|
156
|
+
detail: previewPath(nextCwd, 72) || "Ready",
|
|
106
157
|
});
|
|
107
158
|
startHeartbeat();
|
|
108
159
|
await flush();
|
|
@@ -176,8 +227,32 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
|
|
|
176
227
|
toolName,
|
|
177
228
|
});
|
|
178
229
|
},
|
|
179
|
-
|
|
230
|
+
/** @param {TurnEndEventLike} event */
|
|
231
|
+
onTurnEnd(event) {
|
|
180
232
|
if (!snapshot.agentActive) return;
|
|
233
|
+
const rawMessage = event?.message;
|
|
234
|
+
const message =
|
|
235
|
+
rawMessage && typeof rawMessage === "object"
|
|
236
|
+
? /** @type {Record<string, unknown>} */ (rawMessage)
|
|
237
|
+
: null;
|
|
238
|
+
const stopReason = String(message?.stopReason ?? "");
|
|
239
|
+
if (stopReason) lastStopReason = stopReason;
|
|
240
|
+
if (stopReason === "error") {
|
|
241
|
+
const errorText = previewText(message?.errorMessage, 104) || "Provider error";
|
|
242
|
+
update({
|
|
243
|
+
state: "error",
|
|
244
|
+
phase: "Needs attention",
|
|
245
|
+
detail: errorText,
|
|
246
|
+
errorMessage: errorText,
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (stopReason === "aborted") {
|
|
251
|
+
update({
|
|
252
|
+
detail: snapshot.assistantPreview || snapshot.detail || "Stopped",
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
181
256
|
update({
|
|
182
257
|
state: snapshot.errorMessage ? "error" : "thinking",
|
|
183
258
|
phase: snapshot.errorMessage ? "Needs attention" : "Thinking",
|
|
@@ -187,11 +262,12 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
|
|
|
187
262
|
onAgentSettled() {
|
|
188
263
|
const detail =
|
|
189
264
|
snapshot.errorMessage || snapshot.assistantPreview || snapshot.lastPromptPreview || "Done";
|
|
265
|
+
const aborted = !snapshot.errorMessage && lastStopReason === "aborted";
|
|
190
266
|
update({
|
|
191
267
|
agentActive: false,
|
|
192
268
|
agentStartedAt: null,
|
|
193
269
|
state: snapshot.errorMessage ? "error" : "success",
|
|
194
|
-
phase: snapshot.errorMessage ? "Stopped" : "Done",
|
|
270
|
+
phase: snapshot.errorMessage ? "Stopped" : aborted ? "Stopped" : "Done",
|
|
195
271
|
detail,
|
|
196
272
|
toolName: "",
|
|
197
273
|
toolTarget: "",
|
|
@@ -205,7 +281,10 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
|
|
|
205
281
|
flushTimer = null;
|
|
206
282
|
}
|
|
207
283
|
try {
|
|
208
|
-
await
|
|
284
|
+
await removePublisher({
|
|
285
|
+
sessionId: snapshot.sessionId,
|
|
286
|
+
publisherId: snapshot.publisherId,
|
|
287
|
+
});
|
|
209
288
|
} catch {
|
|
210
289
|
// ignore broker absence on shutdown
|
|
211
290
|
}
|