@tryinget/pi-activity-strip 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/Cargo.lock +1045 -0
  6. package/native/panel/Cargo.toml +23 -0
  7. package/native/panel/src/app.rs +606 -0
  8. package/native/panel/src/card_view.rs +303 -0
  9. package/native/panel/src/main.rs +31 -0
  10. package/native/panel/src/protocol.rs +234 -0
  11. package/native/panel/src/runtime.rs +110 -0
  12. package/native/panel/src/style.css +176 -0
  13. package/package.json +13 -1
  14. package/src/broker/server.mjs +43 -7
  15. package/src/broker/session-store.mjs +41 -13
  16. package/src/client/broker-client.mjs +10 -3
  17. package/src/client/session-telemetry.mjs +54 -14
  18. package/src/common/activity-order.mjs +15 -9
  19. package/src/common/alignment-controller.mjs +24 -42
  20. package/src/common/card-display.mjs +5 -0
  21. package/src/common/compatibility.mjs +15 -13
  22. package/src/common/constants.mjs +10 -28
  23. package/src/common/contracts.d.ts +42 -4
  24. package/src/common/contracts.ts +42 -4
  25. package/src/common/niri-focus.mjs +79 -79
  26. package/src/common/protocol.mjs +48 -16
  27. package/src/common/session-cards.mjs +102 -0
  28. package/src/common/status-report.mjs +2 -1
  29. package/src/common/telemetry.mjs +8 -1
  30. package/src/common/terminal-identity.mjs +149 -0
  31. package/src/native/main.mjs +393 -0
  32. package/src/native/panel-projection.mjs +99 -0
  33. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  34. package/src/common/electron.mjs +0 -57
  35. package/src/electron/main.mjs +0 -499
  36. package/src/electron/niri-native-window-runtime.mjs +0 -178
  37. package/src/electron/preload.cjs +0 -50
  38. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  39. package/src/electron/workspace-view-runtime.mjs +0 -360
  40. package/src/ui/strip-html.mjs +0 -500
@@ -0,0 +1,176 @@
1
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
2
+ window {
3
+ background: transparent;
4
+ color: rgba(247, 250, 255, 0.96);
5
+ font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
6
+ }
7
+
8
+ .brand,
9
+ .cards-panel {
10
+ background: linear-gradient(to bottom, rgba(12, 19, 33, 0.96), rgba(8, 13, 22, 0.92));
11
+ border: 1px solid rgba(255, 255, 255, 0.08);
12
+ border-radius: 20px;
13
+ }
14
+
15
+ .brand {
16
+ padding: 10px 12px;
17
+ }
18
+
19
+ .brand-eyebrow,
20
+ .meta,
21
+ .card-state,
22
+ .card-tool,
23
+ .inspector-key {
24
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
25
+ }
26
+
27
+ .brand-eyebrow {
28
+ color: #79b8ff;
29
+ font-size: 9px;
30
+ letter-spacing: 1px;
31
+ }
32
+
33
+ .brand-title {
34
+ color: rgba(247, 250, 255, 0.96);
35
+ font-size: 18px;
36
+ font-weight: 650;
37
+ }
38
+
39
+ .brand-subtitle {
40
+ color: rgba(194, 205, 224, 0.74);
41
+ font-size: 10px;
42
+ }
43
+
44
+ .meta {
45
+ color: rgba(167, 180, 203, 0.58);
46
+ font-size: 9px;
47
+ }
48
+
49
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
50
+ .cards-panel > viewport {
51
+ background: linear-gradient(to bottom, rgba(10, 16, 28, 0.94), rgba(6, 10, 18, 0.9));
52
+ border-radius: 20px;
53
+ padding: 6px 8px 8px;
54
+ }
55
+
56
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
57
+ .cards-panel scrollbar {
58
+ opacity: 0;
59
+ min-width: 0;
60
+ min-height: 0;
61
+ }
62
+
63
+ .card {
64
+ color: rgba(247, 250, 255, 0.96);
65
+ background: linear-gradient(to bottom, rgba(13, 20, 35, 0.98), rgba(9, 14, 24, 0.96));
66
+ border: 1px solid rgba(255, 255, 255, 0.14);
67
+ border-left: 3px solid #79b8ff;
68
+ border-radius: 16px;
69
+ padding: 8px 10px 8px 12px;
70
+ box-shadow: none;
71
+ transition: 120ms ease;
72
+ }
73
+
74
+ .card:hover,
75
+ .card:focus-visible,
76
+ .card.open {
77
+ background: linear-gradient(to bottom, rgba(18, 28, 47, 0.99), rgba(10, 16, 28, 0.97));
78
+ border-color: rgba(121, 184, 255, 0.72);
79
+ }
80
+
81
+ .card.current {
82
+ border-left-width: 5px;
83
+ border-color: rgba(150, 202, 255, 0.92);
84
+ outline: 1px solid rgba(121, 184, 255, 0.38);
85
+ outline-offset: -3px;
86
+ }
87
+
88
+ .card.stalled {
89
+ opacity: 0.72;
90
+ }
91
+
92
+ .card.stalled .card-phase {
93
+ text-decoration-line: line-through;
94
+ }
95
+
96
+ .card.state-thinking {
97
+ border-left-color: #8ab4ff;
98
+ }
99
+ .card.state-tool {
100
+ border-left-color: #ffd166;
101
+ }
102
+ .card.state-waiting {
103
+ border-left-color: #ff9f7a;
104
+ }
105
+ .card.state-success {
106
+ border-left-color: #57d9a3;
107
+ }
108
+ .card.state-error {
109
+ border-left-color: #ff7d7d;
110
+ }
111
+
112
+ .card-repo {
113
+ color: rgba(247, 250, 255, 0.96);
114
+ font-size: 12px;
115
+ font-weight: 650;
116
+ }
117
+
118
+ .card-phase,
119
+ .inspector-value {
120
+ color: rgba(194, 205, 224, 0.76);
121
+ font-size: 10px;
122
+ }
123
+
124
+ .card-state {
125
+ border: 1px solid rgba(121, 184, 255, 0.36);
126
+ border-radius: 999px;
127
+ color: #79b8ff;
128
+ font-size: 8px;
129
+ padding: 4px 6px;
130
+ }
131
+
132
+ .state-tool .card-state {
133
+ color: #ffd166;
134
+ border-color: rgba(255, 209, 102, 0.42);
135
+ }
136
+ .state-waiting .card-state {
137
+ color: #ff9f7a;
138
+ border-color: rgba(255, 159, 122, 0.42);
139
+ }
140
+ .state-success .card-state {
141
+ color: #57d9a3;
142
+ border-color: rgba(87, 217, 163, 0.42);
143
+ }
144
+ .state-error .card-state {
145
+ color: #ff7d7d;
146
+ border-color: rgba(255, 125, 125, 0.42);
147
+ }
148
+
149
+ .card-tool,
150
+ .card-elapsed,
151
+ .inspector-key {
152
+ color: rgba(167, 180, 203, 0.58);
153
+ font-size: 8px;
154
+ }
155
+
156
+ .inspector {
157
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
158
+ padding-top: 9px;
159
+ }
160
+
161
+ .inspector-value {
162
+ line-height: 1.25;
163
+ }
164
+
165
+ .activation {
166
+ color: rgba(167, 180, 203, 0.72);
167
+ font-size: 9px;
168
+ }
169
+
170
+ .activation.success {
171
+ color: #57d9a3;
172
+ }
173
+
174
+ .activation.error {
175
+ color: #ff7d7d;
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryinget/pi-activity-strip",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -26,6 +26,12 @@
26
26
  "engines": {
27
27
  "node": ">=22.19.0"
28
28
  },
29
+ "os": [
30
+ "linux"
31
+ ],
32
+ "cpu": [
33
+ "x64"
34
+ ],
29
35
  "scripts": {
30
36
  "fix": "bash ./scripts/quality-gate.sh fix",
31
37
  "lint": "bash ./scripts/quality-gate.sh lint",
@@ -40,6 +46,8 @@
40
46
  "docs:list:json": "node ~/ai-society/core/agent-scripts/scripts/docs-list.mjs --json",
41
47
  "release:check": "bash ./scripts/release-check.sh",
42
48
  "release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh",
49
+ "native:check": "cd native/panel && RUSTUP_TOOLCHAIN=stable cargo fmt --check && RUSTUP_TOOLCHAIN=stable cargo test",
50
+ "native:build": "bash ./scripts/build-native-panel.sh",
43
51
  "strip:open": "node ./bin/pi-activity-strip.mjs open",
44
52
  "strip:status": "node ./bin/pi-activity-strip.mjs status",
45
53
  "strip:doctor": "node ./bin/pi-activity-strip.mjs doctor",
@@ -56,6 +64,10 @@
56
64
  "extensions/activity-strip.js",
57
65
  "extensions/activity-strip.ts",
58
66
  "src",
67
+ "native/bin",
68
+ "native/panel/Cargo.toml",
69
+ "native/panel/Cargo.lock",
70
+ "native/panel/src",
59
71
  "prompts",
60
72
  "examples",
61
73
  "policy/security-policy.json"
@@ -10,6 +10,8 @@ import net from "node:net";
10
10
  /** @typedef {import("../common/contracts.ts").ActivityStripBrokerOptions} ActivityStripBrokerOptions */
11
11
  import {
12
12
  ACTIVITY_STRIP_BROADCAST_TICK_MS,
13
+ ACTIVITY_STRIP_CLIENT_IDLE_TIMEOUT_MS,
14
+ ACTIVITY_STRIP_MAX_MESSAGE_BYTES,
13
15
  ACTIVITY_STRIP_SOCKET_DIR,
14
16
  ACTIVITY_STRIP_SOCKET_PATH,
15
17
  } from "../common/constants.mjs";
@@ -49,6 +51,8 @@ export class ActivityStripBroker extends EventEmitter {
49
51
  this.getRuntimeStatus = options.getRuntimeStatus ?? (() => undefined);
50
52
  this.focusSession =
51
53
  options.focusSession ?? (async () => ({ ok: false, error: "Focus unavailable" }));
54
+ this.focusStrip =
55
+ options.focusStrip ?? (async () => ({ ok: false, error: "Strip focus unavailable" }));
52
56
  this.server = net.createServer((socket) => this.handleConnection(socket));
53
57
  this.tick = null;
54
58
  }
@@ -98,12 +102,18 @@ export class ActivityStripBroker extends EventEmitter {
98
102
  handleConnection(socket) {
99
103
  let buffer = "";
100
104
  socket.setEncoding("utf8");
105
+ socket.setTimeout?.(ACTIVITY_STRIP_CLIENT_IDLE_TIMEOUT_MS, () => socket.destroy());
101
106
  socket.on("error", (error) => {
102
107
  if (!isExpectedClientDisconnect(error)) this.emit("client-error", error);
103
108
  });
104
109
 
105
110
  socket.on("data", (chunk) => {
106
111
  buffer += chunk;
112
+ if (Buffer.byteLength(buffer, "utf8") > ACTIVITY_STRIP_MAX_MESSAGE_BYTES) {
113
+ this.reply(socket, { ok: false, error: "Activity strip broker message is too large." });
114
+ socket.destroy();
115
+ return;
116
+ }
107
117
  let newlineIndex = buffer.indexOf("\n");
108
118
  while (newlineIndex >= 0) {
109
119
  const line = buffer.slice(0, newlineIndex);
@@ -139,26 +149,52 @@ export class ActivityStripBroker extends EventEmitter {
139
149
  });
140
150
  return;
141
151
  case "focus":
142
- Promise.resolve(this.focusSession(String(message.sessionId ?? "")))
152
+ Promise.resolve(this.focusSession(String(message.cardId ?? message.sessionId ?? "")))
143
153
  .then((result) => this.reply(socket, { type: "focus", ...result }))
144
154
  .catch(() =>
145
155
  this.reply(socket, { ok: false, type: "focus", error: "Focus failed closed." }),
146
156
  );
147
157
  return;
158
+ case "focus-strip":
159
+ Promise.resolve(this.focusStrip())
160
+ .then((result) => this.reply(socket, { type: "focus-strip", ...result }))
161
+ .catch(() =>
162
+ this.reply(socket, {
163
+ ok: false,
164
+ type: "focus-strip",
165
+ error: "Strip focus failed closed.",
166
+ }),
167
+ );
168
+ return;
148
169
  case "shutdown":
149
170
  this.reply(socket, { ok: true, type: "shutdown" });
150
171
  setTimeout(() => {
151
172
  this.emit("shutdown-requested");
152
173
  }, 20);
153
174
  return;
154
- case "remove":
155
- this.store.remove(String(message.sessionId ?? ""), String(message.publisherId ?? ""));
156
- this.emitSnapshot();
175
+ case "remove": {
176
+ const accepted = this.store.remove(
177
+ String(message.sessionId ?? ""),
178
+ String(message.publisherId ?? ""),
179
+ );
180
+ if (accepted) this.emitSnapshot();
181
+ this.reply(socket, {
182
+ ok: accepted,
183
+ type: "remove",
184
+ ...(accepted ? {} : { error: "Activity strip broker rejected remove." }),
185
+ });
157
186
  return;
158
- case "upsert":
159
- this.store.upsert(message.session);
160
- this.emitSnapshot();
187
+ }
188
+ case "upsert": {
189
+ const accepted = this.store.upsert(message.session);
190
+ if (accepted) this.emitSnapshot();
191
+ this.reply(socket, {
192
+ ok: accepted,
193
+ type: "upsert",
194
+ ...(accepted ? {} : { error: "Activity strip broker rejected upsert." }),
195
+ });
161
196
  return;
197
+ }
162
198
  default:
163
199
  this.reply(socket, { ok: false, error: `Unsupported message type: ${message.type}` });
164
200
  }
@@ -4,7 +4,10 @@
4
4
  // - "changing session retention, upsert semantics, or broker snapshot ordering"
5
5
  // ---
6
6
 
7
- import { ACTIVITY_STRIP_STALE_AFTER_MS } from "../common/constants.mjs";
7
+ import {
8
+ ACTIVITY_STRIP_MAX_SESSIONS,
9
+ ACTIVITY_STRIP_STALE_AFTER_MS,
10
+ } from "../common/constants.mjs";
8
11
  import { normalizeSessionSnapshot, sortSessions } from "../common/protocol.mjs";
9
12
 
10
13
  /** @typedef {import("../common/contracts.ts").SessionSnapshot} SessionSnapshot */
@@ -25,36 +28,61 @@ export class SessionStore {
25
28
  /** @param {SessionStoreOptions} [options] */
26
29
  constructor(options = {}) {
27
30
  this.staleAfterMs = options.staleAfterMs ?? ACTIVITY_STRIP_STALE_AFTER_MS;
28
- /** @type {Map<string, SessionSnapshot>} */
31
+ this.maxSessions = options.maxSessions ?? ACTIVITY_STRIP_MAX_SESSIONS;
32
+ /** @type {Map<string, {session: SessionSnapshot; receivedAt: number}>} */
29
33
  this.sessions = new Map();
34
+ /** @type {Map<string, number>} */
35
+ this.tombstones = new Map();
30
36
  }
31
37
 
32
- /** @param {Partial<SessionSnapshot> | Record<string, unknown>} session */
33
- upsert(session) {
38
+ /** @param {Partial<SessionSnapshot> | Record<string, unknown>} session @param {number} [receivedAt] */
39
+ upsert(session, receivedAt = Date.now()) {
40
+ if (!session || typeof session !== "object") return false;
41
+ this.purge(receivedAt);
34
42
  const normalized = normalizeSessionSnapshot(session);
35
43
  if (!normalized.sessionId) return false;
36
- this.sessions.set(sessionKey(normalized), normalized);
44
+ const key = sessionKey(normalized);
45
+ if (this.tombstones.has(key)) return false;
46
+ const current = this.sessions.get(key);
47
+ if (
48
+ current &&
49
+ current.session.publisherSequence > 0 &&
50
+ (normalized.publisherSequence === 0 ||
51
+ normalized.publisherSequence <= current.session.publisherSequence)
52
+ ) {
53
+ return false;
54
+ }
55
+ if (!current && this.sessions.size >= this.maxSessions) return false;
56
+ this.sessions.set(key, { session: normalized, receivedAt });
37
57
  return true;
38
58
  }
39
59
 
40
- /** @param {string} sessionId @param {string} [publisherId] */
41
- remove(sessionId, publisherId) {
60
+ /** @param {string} sessionId @param {string} [publisherId] @param {number} [removedAt] */
61
+ remove(sessionId, publisherId, removedAt = Date.now()) {
42
62
  const id = String(sessionId ?? "");
43
63
  if (!id) return false;
44
- return publisherId
45
- ? this.sessions.delete(`${id}|${String(publisherId)}`)
46
- : this.sessions.delete(id);
64
+ if (!publisherId) return this.sessions.delete(id);
65
+ const key = `${id}|${String(publisherId)}`;
66
+ this.purge(removedAt);
67
+ if (!this.sessions.has(key)) return false;
68
+ if (!this.tombstones.has(key) && this.tombstones.size >= this.maxSessions) return false;
69
+ this.sessions.delete(key);
70
+ this.tombstones.set(key, removedAt);
71
+ return true;
47
72
  }
48
73
 
49
74
  /** @param {number} [now] */
50
75
  purge(now = Date.now()) {
51
76
  let changed = false;
52
- for (const [sessionId, session] of this.sessions.entries()) {
53
- if (now - session.updatedAt > this.staleAfterMs) {
77
+ for (const [sessionId, entry] of this.sessions.entries()) {
78
+ if (now - entry.receivedAt > this.staleAfterMs) {
54
79
  this.sessions.delete(sessionId);
55
80
  changed = true;
56
81
  }
57
82
  }
83
+ for (const [sessionId, removedAt] of this.tombstones.entries()) {
84
+ if (now - removedAt > this.staleAfterMs) this.tombstones.delete(sessionId);
85
+ }
58
86
  return changed;
59
87
  }
60
88
 
@@ -63,7 +91,7 @@ export class SessionStore {
63
91
  this.purge(now);
64
92
  return {
65
93
  generatedAt: now,
66
- sessions: sortSessions([...this.sessions.values()]),
94
+ sessions: sortSessions([...this.sessions.values()].map((entry) => entry.session)),
67
95
  };
68
96
  }
69
97
  }
@@ -112,7 +112,12 @@ export async function requestBrokerShutdown(options = {}) {
112
112
 
113
113
  /** @param {SessionSnapshot} session @param {BrokerClientOptions} [options] */
114
114
  export async function publishSessionSnapshot(session, options = {}) {
115
- await sendBrokerMessage(makeMessage("upsert", { session }), options);
115
+ const result = await sendBrokerMessage(makeMessage("upsert", { session }), {
116
+ ...options,
117
+ expectReply: true,
118
+ });
119
+ if (result?.ok !== true)
120
+ throw new Error(result?.error || "Activity strip broker rejected upsert.");
116
121
  }
117
122
 
118
123
  /** @param {{ sessionId: string; publisherId: string }} session @param {BrokerClientOptions} [options] */
@@ -125,11 +130,13 @@ export async function removeSession(session, options = {}) {
125
130
  } else {
126
131
  fallbackId = String(session ?? "");
127
132
  }
128
- await sendBrokerMessage(
133
+ const result = await sendBrokerMessage(
129
134
  makeMessage("remove", {
130
135
  sessionId: String(record.sessionId ?? fallbackId),
131
136
  publisherId: String(record.publisherId ?? ""),
132
137
  }),
133
- options,
138
+ { ...options, expectReply: true },
134
139
  );
140
+ if (result?.ok !== true)
141
+ throw new Error(result?.error || "Activity strip broker rejected remove.");
135
142
  }
@@ -30,6 +30,7 @@ import {
30
30
  extractToolResultText,
31
31
  summarizeToolResult,
32
32
  } from "../common/telemetry.mjs";
33
+ import { resolveTerminalIdentity as resolveTerminalIdentityDefault } from "../common/terminal-identity.mjs";
33
34
  import { publishSessionSnapshot, removeSession } from "./broker-client.mjs";
34
35
 
35
36
  function now() {
@@ -47,6 +48,9 @@ export function createSessionTelemetry({
47
48
  pi,
48
49
  cwd = process.cwd(),
49
50
  sessionName = "",
51
+ env = process.env,
52
+ stdinIsTTY = Boolean(process.stdin.isTTY),
53
+ resolveTerminalIdentity = resolveTerminalIdentityDefault,
50
54
  transport,
51
55
  } = {}) {
52
56
  const publish = transport?.publish ?? publishSessionSnapshot;
@@ -62,6 +66,9 @@ export function createSessionTelemetry({
62
66
  let lastStopReason = "";
63
67
  let confirmedSignature = "";
64
68
  let retryIndex = 0;
69
+ /** @type {Promise<void> | null} */
70
+ let flushPromise = null;
71
+ let flushAgain = false;
65
72
 
66
73
  /** Signature of the display-relevant fields, used to detect lost transitions. */
67
74
  function publishSignature() {
@@ -75,15 +82,20 @@ export function createSessionTelemetry({
75
82
  snapshot.toolName,
76
83
  snapshot.toolTarget,
77
84
  snapshot.errorMessage,
85
+ snapshot.terminalKey,
78
86
  ].join("\u001f");
79
87
  }
80
88
 
81
- async function flush() {
89
+ function clearScheduledFlush() {
90
+ if (!flushTimer) return;
91
+ clearTimeout(flushTimer);
82
92
  flushTimer = null;
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.
93
+ }
94
+
95
+ async function publishLatest() {
96
+ // Heartbeats refresh transport liveness only; lastEventAt remains the last real event.
86
97
  snapshot.updatedAt = now();
98
+ snapshot.publisherSequence += 1;
87
99
  snapshot.repoLabel = formatRepoLabel(
88
100
  snapshot.cwd,
89
101
  pi?.getSessionName?.() ?? snapshot.sessionName,
@@ -91,29 +103,48 @@ export function createSessionTelemetry({
91
103
  snapshot.sessionName = compactWhitespace(pi?.getSessionName?.() ?? snapshot.sessionName);
92
104
  const signature = publishSignature();
93
105
  try {
94
- await publish(snapshot);
106
+ await publish({ ...snapshot });
95
107
  confirmedSignature = signature;
96
108
  retryIndex = 0;
97
109
  } catch {
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
110
  if (
111
+ !disposed &&
102
112
  signature !== confirmedSignature &&
103
113
  retryIndex < ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS.length
104
114
  ) {
105
- scheduleFlush(ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS[retryIndex]);
115
+ const delayMs = ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS[retryIndex];
106
116
  retryIndex += 1;
117
+ scheduleFlush(delayMs);
107
118
  }
108
119
  }
109
120
  }
110
121
 
111
- function scheduleFlush(delayMs = ACTIVITY_STRIP_SEND_THROTTLE_MS) {
122
+ async function flush() {
123
+ clearScheduledFlush();
112
124
  if (disposed) return;
113
- if (flushTimer) return;
125
+ if (flushPromise) {
126
+ flushAgain = true;
127
+ return flushPromise;
128
+ }
129
+ flushPromise = (async () => {
130
+ do {
131
+ flushAgain = false;
132
+ if (disposed) break;
133
+ await publishLatest();
134
+ } while (flushAgain && !disposed);
135
+ })().finally(() => {
136
+ flushPromise = null;
137
+ });
138
+ return flushPromise;
139
+ }
140
+
141
+ function scheduleFlush(delayMs = ACTIVITY_STRIP_SEND_THROTTLE_MS) {
142
+ if (disposed || flushTimer) return;
114
143
  flushTimer = setTimeout(() => {
144
+ flushTimer = null;
115
145
  flush().catch(() => {});
116
146
  }, delayMs);
147
+ flushTimer.unref?.();
117
148
  }
118
149
 
119
150
  function startHeartbeat() {
@@ -149,8 +180,15 @@ export function createSessionTelemetry({
149
180
  async onSessionStart(ctx) {
150
181
  const exactSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "").trim();
151
182
  const nextCwd = ctx?.cwd ?? snapshot.cwd;
183
+ const terminalIdentity = resolveTerminalIdentity({
184
+ env,
185
+ hasUI: Boolean(ctx?.hasUI),
186
+ stdinIsTTY,
187
+ processId: process.pid,
188
+ });
152
189
  update({
153
190
  ...(exactSessionId ? { sessionId: exactSessionId } : {}),
191
+ ...terminalIdentity,
154
192
  cwd: nextCwd,
155
193
  sessionName: pi?.getSessionName?.() ?? snapshot.sessionName,
156
194
  detail: previewPath(nextCwd, 72) || "Ready",
@@ -276,9 +314,11 @@ export function createSessionTelemetry({
276
314
  async shutdown() {
277
315
  disposed = true;
278
316
  stopHeartbeat();
279
- if (flushTimer) {
280
- clearTimeout(flushTimer);
281
- flushTimer = null;
317
+ clearScheduledFlush();
318
+ try {
319
+ await flushPromise;
320
+ } catch {
321
+ // Broker absence remains non-fatal; publisher removal is still attempted below.
282
322
  }
283
323
  try {
284
324
  await removePublisher({
@@ -6,6 +6,11 @@
6
6
 
7
7
  const ACTIVE_STATES = new Set(["thinking", "tool", "waiting"]);
8
8
 
9
+ /** @param {{cardId?: string; sessionId?: string}} session */
10
+ export function activityCardKey(session) {
11
+ return String(session?.cardId ?? session?.sessionId ?? "");
12
+ }
13
+
9
14
  /** @param {{ state?: string; agentActive?: boolean }} session */
10
15
  export function isActiveSession(session) {
11
16
  return Boolean(session?.agentActive || ACTIVE_STATES.has(String(session?.state ?? "")));
@@ -24,18 +29,19 @@ export function isMonitoringSession(session) {
24
29
  * Reconcile at the calm clock: green settled/monitoring cards first, then active cards,
25
30
  * then other inactive cards. Preserve previous/manual order inside each group and append
26
31
  * new cards deterministically.
27
- * @param {Array<{ sessionId: string; state?: string; agentActive?: boolean; toolName?: string; toolTarget?: string; updatedAt?: number }>} sessions
32
+ * @param {Array<{ sessionId: string; cardId?: string; state?: string; agentActive?: boolean; toolName?: string; toolTarget?: string; updatedAt?: number }>} sessions
28
33
  * @param {string[]} previousOrder
29
34
  * @param {{ regroup?: boolean }} [options]
30
35
  */
31
36
  export function reconcileActivityOrder(sessions, previousOrder = [], { regroup = true } = {}) {
32
- const byId = new Map(sessions.map((session) => [session.sessionId, session]));
37
+ const byId = new Map(sessions.map((session) => [activityCardKey(session), session]));
33
38
  const surviving = previousOrder.filter((id) => byId.has(id));
34
39
  const known = new Set(surviving);
35
40
  for (const session of sessions) {
36
- if (!session.sessionId || known.has(session.sessionId)) continue;
37
- surviving.push(session.sessionId);
38
- known.add(session.sessionId);
41
+ const id = activityCardKey(session);
42
+ if (!id || known.has(id)) continue;
43
+ surviving.push(id);
44
+ known.add(id);
39
45
  }
40
46
  if (!regroup) return surviving;
41
47
 
@@ -45,16 +51,16 @@ export function reconcileActivityOrder(sessions, previousOrder = [], { regroup =
45
51
  const rightPriority = isMonitoringSession(right) ? 2 : isActiveSession(right) ? 1 : 0;
46
52
  const groupDelta = rightPriority - leftPriority;
47
53
  if (groupDelta) return groupDelta;
48
- const leftPrior = priorIndex.get(left.sessionId);
49
- const rightPrior = priorIndex.get(right.sessionId);
54
+ const leftPrior = priorIndex.get(activityCardKey(left));
55
+ const rightPrior = priorIndex.get(activityCardKey(right));
50
56
  if (leftPrior != null || rightPrior != null) {
51
57
  return (leftPrior ?? Number.MAX_SAFE_INTEGER) - (rightPrior ?? Number.MAX_SAFE_INTEGER);
52
58
  }
53
59
  const updateDelta = Number(right.updatedAt ?? 0) - Number(left.updatedAt ?? 0);
54
60
  if (updateDelta) return updateDelta;
55
- return left.sessionId.localeCompare(right.sessionId);
61
+ return activityCardKey(left).localeCompare(activityCardKey(right));
56
62
  });
57
- return ordered.map((session) => session.sessionId).filter((id) => byId.has(id));
63
+ return ordered.map(activityCardKey).filter((id) => byId.has(id));
58
64
  }
59
65
 
60
66
  /** @param {string[]} order @param {string} sessionId @param {-1 | 1} delta */