@tryinget/pi-activity-strip 0.4.0 → 0.6.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 (61) hide show
  1. package/README.md +136 -157
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +50 -60
  4. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/Cargo.lock +1045 -0
  7. package/native/panel/Cargo.toml +23 -0
  8. package/native/panel/src/app.rs +606 -0
  9. package/native/panel/src/card_view.rs +602 -0
  10. package/native/panel/src/main.rs +31 -0
  11. package/native/panel/src/protocol.rs +297 -0
  12. package/native/panel/src/runtime.rs +151 -0
  13. package/native/panel/src/style.css +250 -0
  14. package/package.json +16 -4
  15. package/src/broker/server.mjs +43 -7
  16. package/src/broker/session-store.mjs +41 -13
  17. package/src/client/broker-client.mjs +10 -3
  18. package/src/client/session-telemetry.mjs +54 -14
  19. package/src/common/activity-order.mjs +15 -9
  20. package/src/common/agent-identity.mjs +163 -0
  21. package/src/common/ak-tasks.mjs +354 -0
  22. package/src/common/alignment-controller.mjs +24 -42
  23. package/src/common/card-display.mjs +5 -0
  24. package/src/common/claude-events.mjs +190 -0
  25. package/src/common/claude-hook-config.mjs +56 -0
  26. package/src/common/claude-transcript.mjs +163 -0
  27. package/src/common/codex-transcript.mjs +153 -0
  28. package/src/common/compatibility.mjs +28 -13
  29. package/src/common/constants.mjs +10 -28
  30. package/src/common/contracts.d.ts +68 -4
  31. package/src/common/contracts.ts +105 -4
  32. package/src/common/ghostty-present.mjs +100 -0
  33. package/src/common/ghostty-theme.mjs +149 -0
  34. package/src/common/niri-focus.mjs +209 -140
  35. package/src/common/protocol.mjs +48 -16
  36. package/src/common/session-cards.mjs +105 -0
  37. package/src/common/status-report.mjs +53 -1
  38. package/src/common/surface-bindings.mjs +234 -0
  39. package/src/common/telemetry.mjs +8 -1
  40. package/src/common/terminal-identity.mjs +212 -0
  41. package/src/common/window-placement.mjs +59 -0
  42. package/src/common/workspace-view.mjs +161 -0
  43. package/src/native/agent-discovery.mjs +294 -0
  44. package/src/native/ak-runtime.mjs +164 -0
  45. package/src/native/codex-discovery.mjs +128 -0
  46. package/src/native/ghostty-tab-inventory.py +99 -0
  47. package/src/native/height-repair.mjs +154 -0
  48. package/src/native/main.mjs +499 -0
  49. package/src/native/panel-projection.mjs +161 -0
  50. package/src/native/placement.mjs +225 -0
  51. package/src/native/tab-inventory.mjs +188 -0
  52. package/src/native/theme-runtime.mjs +72 -0
  53. package/src/native/theme-source.mjs +197 -0
  54. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
  55. package/src/common/electron.mjs +0 -57
  56. package/src/electron/main.mjs +0 -499
  57. package/src/electron/niri-native-window-runtime.mjs +0 -178
  58. package/src/electron/preload.cjs +0 -50
  59. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  60. package/src/electron/workspace-view-runtime.mjs +0 -360
  61. package/src/ui/strip-html.mjs +0 -500
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.6.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"
@@ -76,9 +88,9 @@
76
88
  },
77
89
  "devDependencies": {
78
90
  "@biomejs/biome": "2.4.4",
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",
91
+ "@earendil-works/pi-agent-core": "0.84.4",
92
+ "@earendil-works/pi-ai": "0.84.4",
93
+ "@earendil-works/pi-coding-agent": "0.84.4",
82
94
  "@types/node": "22.14.0",
83
95
  "@typescript/native-preview": "7.0.0-dev.20260417.1",
84
96
  "typebox": "1.3.7",
@@ -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 */
@@ -0,0 +1,163 @@
1
+ // ---
2
+ // summary: "recognizes terminal-based coding agents and shapes them into activity-strip session records"
3
+ // read_when:
4
+ // - "adding an agent CLI, changing agent card fields, or changing agent title matching"
5
+ // ---
6
+
7
+ import path from "node:path";
8
+
9
+ /**
10
+ * Terminal-based coding agents that occupy a Ghostty tab the way Pi does. Recognition is by the
11
+ * executable name of the process that owns the terminal, never by a window title, so a tab is
12
+ * admitted on process evidence alone.
13
+ * @type {ReadonlyArray<{kind: string; label: string; commands: readonly string[]}>}
14
+ */
15
+ export const AGENT_CATALOG = Object.freeze([
16
+ { kind: "claude", label: "Claude Code", commands: Object.freeze(["claude"]) },
17
+ { kind: "codex", label: "Codex", commands: Object.freeze(["codex"]) },
18
+ { kind: "gemini", label: "Gemini", commands: Object.freeze(["gemini"]) },
19
+ { kind: "amp", label: "Amp", commands: Object.freeze(["amp"]) },
20
+ { kind: "aider", label: "Aider", commands: Object.freeze(["aider"]) },
21
+ { kind: "opencode", label: "opencode", commands: Object.freeze(["opencode"]) },
22
+ { kind: "crush", label: "Crush", commands: Object.freeze(["crush"]) },
23
+ { kind: "goose", label: "Goose", commands: Object.freeze(["goose"]) },
24
+ { kind: "cursor", label: "Cursor Agent", commands: Object.freeze(["cursor-agent"]) },
25
+ { kind: "zcode", label: "zcode", commands: Object.freeze(["zcode"]) },
26
+ ]);
27
+
28
+ /** Pi publishes its own telemetry, so its processes are never discovered this way. */
29
+ const EXCLUDED_COMMANDS = new Set(["pi", "node", "bash", "sh", "zsh", "fish", "python3"]);
30
+ /**
31
+ * Executables that only ever host another program. When one of these is the process name, the
32
+ * command line names the real agent, so it is worth the extra read to look.
33
+ */
34
+ const RUNTIME_COMMANDS = [/^node/i, /^python/i, /^(bash|sh|zsh|fish|dash|env)$/i, /^(deno|bun)$/i];
35
+
36
+ /** @param {unknown} command */
37
+ export function isRuntimeCommand(command) {
38
+ const name = path.basename(String(command ?? "").trim());
39
+ return name.length > 0 && RUNTIME_COMMANDS.some((pattern) => pattern.test(name));
40
+ }
41
+ export const AGENT_ACTIVE_WINDOW_MS = 45_000;
42
+
43
+ /** @param {NodeJS.ProcessEnv} [env] */
44
+ export function agentCatalogFor(env = process.env) {
45
+ const disabled = new Set(
46
+ String(env.PI_ACTIVITY_STRIP_AGENT_KINDS_DISABLED ?? "")
47
+ .split(",")
48
+ .map((value) => value.trim().toLowerCase())
49
+ .filter(Boolean),
50
+ );
51
+ return AGENT_CATALOG.filter((agent) => !disabled.has(agent.kind));
52
+ }
53
+
54
+ /**
55
+ * Classify one process by the executable it runs. `command` is the kernel's comm value, which the
56
+ * kernel truncates to 15 characters and which names the runtime rather than the agent when the
57
+ * agent is launched through one; the command line entries name the agent in that case.
58
+ * @param {{command?: unknown; argv0?: unknown; argv1?: unknown; env?: NodeJS.ProcessEnv}} process
59
+ */
60
+ export function classifyAgentProcess({ command, argv0, argv1, env = process.env } = {}) {
61
+ const names = [command, argv0, argv1]
62
+ .map((value) => path.basename(String(value ?? "").trim()).toLowerCase())
63
+ .filter(Boolean);
64
+ if (names.length === 0 || names.every((name) => EXCLUDED_COMMANDS.has(name))) return null;
65
+ for (const agent of agentCatalogFor(env)) {
66
+ if (agent.commands.some((candidate) => names.includes(candidate))) return agent;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** @param {unknown} cwd */
72
+ export function repoLabelFor(cwd) {
73
+ const normalized = String(cwd ?? "").trim();
74
+ if (!normalized || normalized === "/") return "";
75
+ return path.basename(normalized);
76
+ }
77
+
78
+ /**
79
+ * Shape one discovered agent tab into the record the card projection consumes. The record carries a
80
+ * terminal identity, so it groups and places exactly like a Pi terminal card, but its session id is
81
+ * namespaced by agent kind and can never be mistaken for a Pi session id.
82
+ * @param {{
83
+ * kind: string;
84
+ * label: string;
85
+ * processId: number;
86
+ * cwd: string;
87
+ * terminalKey: string;
88
+ * terminalFamily: string;
89
+ * terminalSurfaceId: string;
90
+ * sessionKey?: string;
91
+ * titleSuffix?: string;
92
+ * startedAt: number;
93
+ * lastEventAt?: number;
94
+ * now?: number;
95
+ * telemetry?: Partial<import("./claude-transcript.mjs").ClaudeTelemetry> | null;
96
+ * }} tab
97
+ */
98
+ export function agentSessionRecord({
99
+ kind,
100
+ label,
101
+ processId,
102
+ cwd,
103
+ terminalKey,
104
+ terminalFamily,
105
+ terminalSurfaceId,
106
+ sessionKey = "",
107
+ titleSuffix = "",
108
+ startedAt,
109
+ lastEventAt = 0,
110
+ now = Date.now(),
111
+ telemetry = null,
112
+ }) {
113
+ // Process start times and file modification times are fractional milliseconds, while the panel
114
+ // protocol carries whole-millisecond integers, so every timestamp is rounded at the boundary.
115
+ const startedAtMs = Math.round(startedAt) || 0;
116
+ const lastEventMs = Math.round(lastEventAt) || 0;
117
+ const nowMs = Math.round(now) || 0;
118
+ const activity = lastEventMs > 0 ? lastEventMs : startedAtMs;
119
+ // Reported activity decides liveness. A published state only counts while the transcript is
120
+ // still moving; a stale "tool" record is not evidence that a tool is running right now.
121
+ const recent = lastEventMs > 0 && nowMs - lastEventMs <= AGENT_ACTIVE_WINDOW_MS;
122
+ const reported = String(telemetry?.state ?? "");
123
+ // "waiting" and "error" describe a session that is stopped on purpose, so they survive the
124
+ // liveness window: a permission prompt can sit unanswered for a long time and is still true.
125
+ const sticky = reported === "waiting" || reported === "error";
126
+ const busy = reported === "tool" || reported === "thinking";
127
+ const active = sticky || (recent && (busy || !reported));
128
+ const state = sticky ? reported : active ? (busy ? reported : "thinking") : "idle";
129
+ const toolName = active ? String(telemetry?.toolName ?? "") : "";
130
+ const toolTarget = active ? String(telemetry?.toolTarget ?? "") : "";
131
+ const detail = [toolTarget, String(telemetry?.assistantPreview ?? ""), cwd].find(Boolean) ?? "";
132
+ return {
133
+ sessionId: `agent:${kind}:${sessionKey || processId}`,
134
+ publisherId: `agent-scan:${processId}`,
135
+ publisherSequence: 0,
136
+ processId,
137
+ agentKind: kind,
138
+ agentSessionKey: sessionKey,
139
+ terminalKind: "ghostty-surface",
140
+ terminalKey,
141
+ terminalFamily,
142
+ terminalSurfaceId,
143
+ titleSuffix,
144
+ cwd,
145
+ repoLabel: repoLabelFor(cwd),
146
+ sessionName: "",
147
+ agentLabel: label,
148
+ phase: String(telemetry?.title ?? "").trim() || label,
149
+ detail,
150
+ assistantPreview: String(telemetry?.assistantPreview ?? ""),
151
+ toolName,
152
+ toolTarget,
153
+ state,
154
+ turnIndex: Number(telemetry?.turnIndex ?? 0) || 0,
155
+ updatedAt: nowMs,
156
+ lastEventAt: activity,
157
+ startedAt: startedAtMs,
158
+ agentStartedAt: active ? activity : null,
159
+ agentActive: active,
160
+ lastPromptPreview: String(telemetry?.lastPrompt ?? ""),
161
+ errorMessage: "",
162
+ };
163
+ }