@tryinget/pi-activity-strip 0.3.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.
@@ -65,5 +65,5 @@ await publishSessionSnapshot(sessions[1]);
65
65
 
66
66
  await delay(2000);
67
67
  for (const session of sessions) {
68
- await removeSession(session.sessionId);
68
+ await removeSession({ sessionId: session.sessionId, publisherId: session.publisherId });
69
69
  }
@@ -194,8 +194,8 @@ export default function activityStripExtension(pi) {
194
194
  telemetry.onToolExecutionEnd(event);
195
195
  });
196
196
 
197
- pi.on("turn_end", async () => {
198
- telemetry.onTurnEnd();
197
+ pi.on("turn_end", async (event) => {
198
+ telemetry.onTurnEnd(event);
199
199
  });
200
200
 
201
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.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.83.0",
80
- "@earendil-works/pi-coding-agent": "0.83.0",
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
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": "*",
@@ -152,7 +152,7 @@ export class ActivityStripBroker extends EventEmitter {
152
152
  }, 20);
153
153
  return;
154
154
  case "remove":
155
- this.store.remove(message.sessionId);
155
+ this.store.remove(String(message.sessionId ?? ""), String(message.publisherId ?? ""));
156
156
  this.emitSnapshot();
157
157
  return;
158
158
  case "upsert":
@@ -11,6 +11,16 @@ import { normalizeSessionSnapshot, sortSessions } from "../common/protocol.mjs";
11
11
  /** @typedef {import("../common/contracts.ts").SessionStoreOptions} SessionStoreOptions */
12
12
  /** @typedef {import("../common/contracts.ts").BrokerSnapshot} BrokerSnapshot */
13
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
+
14
24
  export class SessionStore {
15
25
  /** @param {SessionStoreOptions} [options] */
16
26
  constructor(options = {}) {
@@ -23,13 +33,17 @@ export class SessionStore {
23
33
  upsert(session) {
24
34
  const normalized = normalizeSessionSnapshot(session);
25
35
  if (!normalized.sessionId) return false;
26
- this.sessions.set(normalized.sessionId, normalized);
36
+ this.sessions.set(sessionKey(normalized), normalized);
27
37
  return true;
28
38
  }
29
39
 
30
- /** @param {string} sessionId */
31
- remove(sessionId) {
32
- 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);
33
47
  }
34
48
 
35
49
  /** @param {number} [now] */
@@ -115,7 +115,21 @@ export async function publishSessionSnapshot(session, options = {}) {
115
115
  await sendBrokerMessage(makeMessage("upsert", { session }), options);
116
116
  }
117
117
 
118
- /** @param {string} sessionId @param {BrokerClientOptions} [options] */
119
- export async function removeSession(sessionId, options = {}) {
120
- 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
+ );
121
135
  }
@@ -5,6 +5,7 @@
5
5
  // ---
6
6
 
7
7
  /** @typedef {import("../common/contracts.ts").SessionSnapshot} SessionSnapshot */
8
+ /** @typedef {import("../common/contracts.ts").TurnEndEventLike} TurnEndEventLike */
8
9
  /** @typedef {import("../common/contracts.ts").SessionTelemetryOptions} SessionTelemetryOptions */
9
10
  /** @typedef {import("../common/contracts.ts").SessionStartContextLike} SessionStartContextLike */
10
11
  /** @typedef {import("../common/contracts.ts").BeforeAgentStartEventLike} BeforeAgentStartEventLike */
@@ -12,6 +13,7 @@
12
13
  /** @typedef {import("../common/contracts.ts").MessageUpdateEventLike} MessageUpdateEventLike */
13
14
  /** @typedef {import("../common/contracts.ts").ToolExecutionEventLike} ToolExecutionEventLike */
14
15
  import {
16
+ ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS,
15
17
  ACTIVITY_STRIP_HEARTBEAT_MS,
16
18
  ACTIVITY_STRIP_SEND_THROTTLE_MS,
17
19
  } from "../common/constants.mjs";
@@ -41,7 +43,14 @@ function extractAssistantDelta(event) {
41
43
  }
42
44
 
43
45
  /** @param {SessionTelemetryOptions} [options] */
44
- 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;
45
54
  /** @type {SessionSnapshot} */
46
55
  let snapshot = createInitialSnapshot({ cwd, sessionName });
47
56
  /** @type {NodeJS.Timeout | null} */
@@ -50,20 +59,52 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
50
59
  let flushTimer = null;
51
60
  let disposed = false;
52
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
+ }
53
80
 
54
81
  async function flush() {
55
82
  flushTimer = null;
56
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.
57
86
  snapshot.updatedAt = now();
58
87
  snapshot.repoLabel = formatRepoLabel(
59
88
  snapshot.cwd,
60
89
  pi?.getSessionName?.() ?? snapshot.sessionName,
61
90
  );
62
91
  snapshot.sessionName = compactWhitespace(pi?.getSessionName?.() ?? snapshot.sessionName);
92
+ const signature = publishSignature();
63
93
  try {
64
- await publishSessionSnapshot(snapshot);
94
+ await publish(snapshot);
95
+ confirmedSignature = signature;
96
+ retryIndex = 0;
65
97
  } catch {
66
- // 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
+ }
67
108
  }
68
109
  }
69
110
 
@@ -95,6 +136,7 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
95
136
  ...snapshot,
96
137
  ...partial,
97
138
  updatedAt: now(),
139
+ lastEventAt: now(),
98
140
  };
99
141
  scheduleFlush();
100
142
  }
@@ -185,8 +227,32 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
185
227
  toolName,
186
228
  });
187
229
  },
188
- onTurnEnd() {
230
+ /** @param {TurnEndEventLike} event */
231
+ onTurnEnd(event) {
189
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
+ }
190
256
  update({
191
257
  state: snapshot.errorMessage ? "error" : "thinking",
192
258
  phase: snapshot.errorMessage ? "Needs attention" : "Thinking",
@@ -196,11 +262,12 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
196
262
  onAgentSettled() {
197
263
  const detail =
198
264
  snapshot.errorMessage || snapshot.assistantPreview || snapshot.lastPromptPreview || "Done";
265
+ const aborted = !snapshot.errorMessage && lastStopReason === "aborted";
199
266
  update({
200
267
  agentActive: false,
201
268
  agentStartedAt: null,
202
269
  state: snapshot.errorMessage ? "error" : "success",
203
- phase: snapshot.errorMessage ? "Stopped" : "Done",
270
+ phase: snapshot.errorMessage ? "Stopped" : aborted ? "Stopped" : "Done",
204
271
  detail,
205
272
  toolName: "",
206
273
  toolTarget: "",
@@ -214,7 +281,10 @@ export function createSessionTelemetry({ pi, cwd = process.cwd(), sessionName =
214
281
  flushTimer = null;
215
282
  }
216
283
  try {
217
- await removeSession(snapshot.sessionId);
284
+ await removePublisher({
285
+ sessionId: snapshot.sessionId,
286
+ publisherId: snapshot.publisherId,
287
+ });
218
288
  } catch {
219
289
  // ignore broker absence on shutdown
220
290
  }
@@ -0,0 +1,48 @@
1
+ // ---
2
+ // summary: "derives card display facts that depend on more than one session field"
3
+ // read_when:
4
+ // - "changing stalled-session detection, duplicate label disambiguation, or card label rendering"
5
+ // ---
6
+
7
+ /**
8
+ * A session is stalled when it claims to be mid-activity but no lifecycle
9
+ * event has arrived for longer than the stall threshold. Wedged streams keep
10
+ * heartbeating liveness, so only real event time can expose them.
11
+ * @param {Record<string, unknown>} session
12
+ * @param {number} nowMs
13
+ * @param {number} stallMs
14
+ */
15
+ export function isStalledSession(session, nowMs, stallMs) {
16
+ if (!session.agentActive) return false;
17
+ const state = String(session.state ?? "");
18
+ if (state !== "thinking" && state !== "tool" && state !== "waiting") return false;
19
+ const anchor = Number(session.lastEventAt || session.updatedAt || 0);
20
+ return anchor > 0 && nowMs - anchor > stallMs;
21
+ }
22
+
23
+ /**
24
+ * @param {Array<Record<string, unknown>>} sessions
25
+ * @returns {Set<string>}
26
+ */
27
+ export function findDuplicateLabels(sessions) {
28
+ const counts = new Map();
29
+ for (const session of sessions) {
30
+ const label = String(session.repoLabel || "pi session");
31
+ counts.set(label, (counts.get(label) || 0) + 1);
32
+ }
33
+ return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([label]) => label));
34
+ }
35
+
36
+ /**
37
+ * Two live processes can share one repo label; append a short process suffix
38
+ * so their cards stay distinguishable at a glance.
39
+ * @param {Record<string, unknown>} session
40
+ * @param {Set<string>} duplicateLabels
41
+ */
42
+ export function disambiguatedRepoLabel(session, duplicateLabels) {
43
+ const label = String(session.repoLabel || "pi session");
44
+ const processId = Number(session.processId ?? 0);
45
+ return duplicateLabels.has(label) && processId > 0
46
+ ? `${label} · ${String(processId).slice(-4)}`
47
+ : label;
48
+ }
@@ -43,3 +43,5 @@ export const DEFAULT_ELECTRON_CANDIDATES = [
43
43
  "electron32",
44
44
  "electron31",
45
45
  ];
46
+ export const ACTIVITY_STRIP_EVENT_STALL_MS = 15 * 60_000;
47
+ export const ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS = [250, 750];
@@ -8,6 +8,7 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
8
8
 
9
9
  export interface SessionSnapshot {
10
10
  sessionId: string;
11
+ publisherId: string;
11
12
  processId: number;
12
13
  cwd: string;
13
14
  repoLabel: string;
@@ -20,6 +21,7 @@ export interface SessionSnapshot {
20
21
  state: SessionState;
21
22
  turnIndex: number;
22
23
  updatedAt: number;
24
+ lastEventAt: number;
23
25
  startedAt: number;
24
26
  agentStartedAt: number | null;
25
27
  agentActive: boolean;
@@ -71,7 +73,7 @@ export interface ActivityStripBrokerOptions {
71
73
  store?: {
72
74
  snapshot(): BrokerSnapshot;
73
75
  upsert(session: Partial<SessionSnapshot> | Record<string, unknown>): boolean;
74
- remove(sessionId: string): boolean;
76
+ remove(sessionId: string, publisherId?: string): boolean;
75
77
  };
76
78
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
77
79
  focusSession?: (sessionId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
@@ -115,6 +117,11 @@ export interface TurnStartEventLike {
115
117
  turnIndex?: number;
116
118
  }
117
119
 
120
+ export interface TurnEndEventLike {
121
+ turnIndex?: number;
122
+ message?: unknown;
123
+ }
124
+
118
125
  export interface AssistantMessageEventLike {
119
126
  type?: string;
120
127
  delta?: unknown;
@@ -136,6 +143,10 @@ export interface SessionTelemetryOptions {
136
143
  pi?: TelemetryPiLike;
137
144
  cwd?: string;
138
145
  sessionName?: string;
146
+ transport?: {
147
+ publish: (session: SessionSnapshot) => Promise<unknown>;
148
+ remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
149
+ };
139
150
  }
140
151
 
141
152
  export interface ActivityStripCompatibilityReport {
@@ -8,6 +8,7 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
8
8
 
9
9
  export interface SessionSnapshot {
10
10
  sessionId: string;
11
+ publisherId: string;
11
12
  processId: number;
12
13
  cwd: string;
13
14
  repoLabel: string;
@@ -20,6 +21,7 @@ export interface SessionSnapshot {
20
21
  state: SessionState;
21
22
  turnIndex: number;
22
23
  updatedAt: number;
24
+ lastEventAt: number;
23
25
  startedAt: number;
24
26
  agentStartedAt: number | null;
25
27
  agentActive: boolean;
@@ -71,7 +73,7 @@ export interface ActivityStripBrokerOptions {
71
73
  store?: {
72
74
  snapshot(): BrokerSnapshot;
73
75
  upsert(session: Partial<SessionSnapshot> | Record<string, unknown>): boolean;
74
- remove(sessionId: string): boolean;
76
+ remove(sessionId: string, publisherId?: string): boolean;
75
77
  };
76
78
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
77
79
  focusSession?: (sessionId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
@@ -115,6 +117,11 @@ export interface TurnStartEventLike {
115
117
  turnIndex?: number;
116
118
  }
117
119
 
120
+ export interface TurnEndEventLike {
121
+ turnIndex?: number;
122
+ message?: unknown;
123
+ }
124
+
118
125
  export interface AssistantMessageEventLike {
119
126
  type?: string;
120
127
  delta?: unknown;
@@ -136,6 +143,10 @@ export interface SessionTelemetryOptions {
136
143
  pi?: TelemetryPiLike;
137
144
  cwd?: string;
138
145
  sessionName?: string;
146
+ transport?: {
147
+ publish: (session: SessionSnapshot) => Promise<unknown>;
148
+ remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
149
+ };
139
150
  }
140
151
 
141
152
  export interface ActivityStripCompatibilityReport {
@@ -25,6 +25,7 @@ export function makeMessage(type, payload = {}) {
25
25
  export function normalizeSessionSnapshot(session = {}) {
26
26
  const now = Date.now();
27
27
  const updatedAt = Number(session.updatedAt ?? now) || now;
28
+ const lastEventAt = Number(session.lastEventAt ?? updatedAt) || updatedAt;
28
29
  const startedAt = Number(session.startedAt ?? updatedAt) || updatedAt;
29
30
  const agentStartedAt =
30
31
  session.agentStartedAt == null ? null : Number(session.agentStartedAt) || null;
@@ -36,6 +37,7 @@ export function normalizeSessionSnapshot(session = {}) {
36
37
 
37
38
  return {
38
39
  sessionId: String(session.sessionId ?? ""),
40
+ publisherId: String(session.publisherId ?? ""),
39
41
  processId: Number(session.processId ?? 0) || 0,
40
42
  cwd: String(session.cwd ?? ""),
41
43
  repoLabel: String(session.repoLabel ?? "pi session"),
@@ -48,6 +50,7 @@ export function normalizeSessionSnapshot(session = {}) {
48
50
  state,
49
51
  turnIndex: Number(session.turnIndex ?? 0) || 0,
50
52
  updatedAt,
53
+ lastEventAt,
51
54
  startedAt,
52
55
  agentStartedAt,
53
56
  agentActive: Boolean(session.agentActive),
@@ -67,15 +67,17 @@ export function createSessionId() {
67
67
  return `${os.hostname()}-${process.pid}-${Date.now()}-${randomUUID().slice(0, 6)}`;
68
68
  }
69
69
 
70
- /** @param {{ cwd?: string; sessionName?: string; sessionId?: string }} [options] @returns {SessionSnapshot} */
70
+ /** @param {{ cwd?: string; sessionName?: string; sessionId?: string; publisherId?: string }} [options] @returns {SessionSnapshot} */
71
71
  export function createInitialSnapshot({
72
72
  cwd = process.cwd(),
73
73
  sessionName = "",
74
74
  sessionId = "",
75
+ publisherId = "",
75
76
  } = {}) {
76
77
  const now = Date.now();
77
78
  return {
78
79
  sessionId: sessionId.trim() || createSessionId(),
80
+ publisherId: publisherId.trim() || randomUUID(),
79
81
  processId: process.pid,
80
82
  cwd,
81
83
  repoLabel: formatRepoLabel(cwd, sessionName),
@@ -88,6 +90,7 @@ export function createInitialSnapshot({
88
90
  state: "idle",
89
91
  turnIndex: 0,
90
92
  updatedAt: now,
93
+ lastEventAt: now,
91
94
  startedAt: now,
92
95
  agentStartedAt: null,
93
96
  agentActive: false,
@@ -10,7 +10,15 @@ import {
10
10
  moveOrderItem,
11
11
  reconcileActivityOrder,
12
12
  } from "../common/activity-order.mjs";
13
- import { ACTIVITY_STRIP_ORDER_REFRESH_MS } from "../common/constants.mjs";
13
+ import {
14
+ disambiguatedRepoLabel,
15
+ findDuplicateLabels,
16
+ isStalledSession,
17
+ } from "../common/card-display.mjs";
18
+ import {
19
+ ACTIVITY_STRIP_EVENT_STALL_MS,
20
+ ACTIVITY_STRIP_ORDER_REFRESH_MS,
21
+ } from "../common/constants.mjs";
14
22
 
15
23
  const ORDER_RUNTIME = [
16
24
  'const ACTIVE_STATES = new Set(["thinking", "tool", "waiting"]);',
@@ -18,6 +26,9 @@ const ORDER_RUNTIME = [
18
26
  isMonitoringSession.toString(),
19
27
  reconcileActivityOrder.toString(),
20
28
  moveOrderItem.toString(),
29
+ isStalledSession.toString(),
30
+ findDuplicateLabels.toString(),
31
+ disambiguatedRepoLabel.toString(),
21
32
  ].join("\n");
22
33
 
23
34
  export function shouldRetainExpandedCard({ hovered, activeElement, documentFocused }) {
@@ -184,6 +195,7 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
184
195
  .placeholder__title { color: var(--text); font-size: 14px; font-weight: 650; }
185
196
  .placeholder__copy { color: var(--muted); font-size: 11px; line-height: 1.35; }
186
197
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
198
+ .card[data-stalled="true"] { opacity: 0.72; } .card[data-stalled="true"] .card__phase { text-decoration: line-through; }
187
199
 
188
200
  @media (prefers-reduced-motion: reduce) {
189
201
  .card { transition: none; }
@@ -208,6 +220,7 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
208
220
  ${ORDER_RUNTIME}
209
221
  ${INTERACTION_RUNTIME}
210
222
  const ORDER_REFRESH_MS = ${ACTIVITY_STRIP_ORDER_REFRESH_MS};
223
+ const EVENT_STALL_MS = ${ACTIVITY_STRIP_EVENT_STALL_MS};
211
224
  const meta = document.getElementById("meta");
212
225
  const cards = document.getElementById("cards");
213
226
  const announcer = document.getElementById("announcer");
@@ -236,7 +249,7 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
236
249
  return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
237
250
  }
238
251
  function formatLastSeen(session) {
239
- const anchor = Number(session.updatedAt || snapshot.generatedAt || Date.now());
252
+ const anchor = Number(session.lastEventAt || session.updatedAt || snapshot.generatedAt || Date.now());
240
253
  return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
241
254
  }
242
255
  function sessionById(sessionId) {
@@ -271,11 +284,13 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
271
284
  ].join("");
272
285
  return card;
273
286
  }
274
- function updateCard(card, session) {
275
- const stateColor = STATE_COLORS[session.state] || "var(--accent)";
276
- const stateLabel = STATE_LABELS[session.state] || session.state || "idle";
287
+ function updateCard(card, session, duplicateLabels) {
288
+ const stalled = isStalledSession(session, Date.now(), EVENT_STALL_MS);
289
+ const stateColor = stalled ? "var(--waiting)" : STATE_COLORS[session.state] || "var(--accent)";
290
+ const stateLabel = stalled ? "stalled" : STATE_LABELS[session.state] || session.state || "idle";
277
291
  const isCurrent = session.sessionId === snapshot.focusedSessionId;
278
292
  card.style.setProperty("--state-color", stateColor);
293
+ card.dataset.stalled = stalled ? "true" : "false";
279
294
  card.dataset.group = isMonitoringSession(session)
280
295
  ? "monitoring"
281
296
  : isActiveSession(session)
@@ -284,9 +299,9 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
284
299
  card.dataset.current = isCurrent ? "true" : "false";
285
300
  if (isCurrent) card.setAttribute("aria-current", "true");
286
301
  else card.removeAttribute("aria-current");
287
- card.setAttribute("aria-label", (isCurrent ? "Current terminal, " : "") + (session.repoLabel || "Pi session") + ", " + (session.phase || "Idle") + ". Press Enter to focus its Ghostty window.");
302
+ card.setAttribute("aria-label", (stalled ? "Stalled, " : isCurrent ? "Current terminal, " : "") + (session.repoLabel || "Pi session") + ", " + (stalled ? "no events recently" : session.phase || "Idle") + ". Press Enter to focus its Ghostty window.");
288
303
  card.title = (isCurrent ? "Current terminal · " : "Focus ") + (session.repoLabel || "Pi session");
289
- setText(card, ".card__repo", session.repoLabel || "pi session");
304
+ setText(card, ".card__repo", disambiguatedRepoLabel(session, duplicateLabels));
290
305
  setText(card, ".card__phase", session.phase || "Idle");
291
306
  setText(card, ".card__state", stateLabel);
292
307
  setText(card, ".tool", session.toolName || session.toolTarget || "monitoring");
@@ -332,6 +347,7 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
332
347
  setExpanded(null, false).catch(() => {});
333
348
  }
334
349
  let targetIndex = 0;
350
+ const duplicateLabels = findDuplicateLabels(sessions);
335
351
  for (const sessionId of orderedIds) {
336
352
  const session = byId.get(sessionId);
337
353
  if (!session) continue;
@@ -340,7 +356,7 @@ export function createStripHtml({ interactive = true, initiallyVisible = true }
340
356
  card = cardTemplate(sessionId);
341
357
  cardById.set(sessionId, card);
342
358
  }
343
- updateCard(card, session);
359
+ updateCard(card, session, duplicateLabels);
344
360
  const nodeAtTarget = cards.children[targetIndex] ?? null;
345
361
  if (nodeAtTarget !== card) cards.insertBefore(card, nodeAtTarget);
346
362
  targetIndex += 1;