@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 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";
@@ -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 () => {
@@ -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.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": "bash ./scripts/docs-list.sh",
39
- "docs:list:workspace": "bash ./scripts/docs-list.sh --workspace --discover",
40
- "docs:list:json": "bash ./scripts/docs-list.sh --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-ai": "^0.80.6",
80
- "@earendil-works/pi-coding-agent": "^0.80.6",
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": "^1.0.0",
84
+ "typebox": "1.3.7",
84
85
  "typescript": "5.9.2"
85
86
  },
86
87
  "overrides": {
87
- "fast-xml-parser": "5.3.6"
88
+ "fast-xml-parser": "5.7.0"
88
89
  },
89
90
  "peerDependencies": {
90
91
  "@earendil-works/pi-ai": "*",
@@ -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(() => {
@@ -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.sessionId, normalized);
36
+ this.sessions.set(sessionKey(normalized), normalized);
21
37
  return true;
22
38
  }
23
39
 
24
- /** @param {string} sessionId */
25
- remove(sessionId) {
26
- return this.sessions.delete(String(sessionId ?? ""));
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} sessionId @param {BrokerClientOptions} [options] */
113
- export async function removeSession(sessionId, options = {}) {
114
- await sendBrokerMessage(makeMessage("remove", { sessionId }), options);
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
  }
@@ -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({ pi, cwd = process.cwd(), sessionName = "" } = {}) {
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 publishSessionSnapshot(snapshot);
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
- cwd: ctx?.cwd ?? snapshot.cwd,
153
+ ...(exactSessionId ? { sessionId: exactSessionId } : {}),
154
+ cwd: nextCwd,
104
155
  sessionName: pi?.getSessionName?.() ?? snapshot.sessionName,
105
- detail: previewPath(ctx?.cwd ?? snapshot.cwd, 72) || "Ready",
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
- onTurnEnd() {
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 removeSession(snapshot.sessionId);
284
+ await removePublisher({
285
+ sessionId: snapshot.sessionId,
286
+ publisherId: snapshot.publisherId,
287
+ });
209
288
  } catch {
210
289
  // ignore broker absence on shutdown
211
290
  }