@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
@@ -0,0 +1,354 @@
1
+ // ---
2
+ // summary: "parses read-only AK task output and joins live session-bound claims onto terminal cards"
3
+ // read_when:
4
+ // - "changing AK claim parsing, lease/vacant-custody rules, or task chip join semantics"
5
+ // ---
6
+
7
+ /**
8
+ * AK is a read-only projection for the strip: these helpers only parse CLI output and derive
9
+ * display facts. They never write, never touch the society database directly, and fail closed
10
+ * (malformed input yields no chips rather than invented state).
11
+ */
12
+
13
+ /** Chip states rendered by the native panel. */
14
+ export const AK_TASK_CHIP_ACTIVE = "active";
15
+ export const AK_TASK_CHIP_DEFERRED = "deferred";
16
+ export const AK_TASK_CHIP_ORPHANED = "orphaned";
17
+
18
+ /** AK5700 claim semantics: only `session-<uuid>` claims can belong to a live Pi terminal. */
19
+ const SESSION_CLAIM_PATTERN =
20
+ /^session-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
21
+ /** Bound the title carried into the panel protocol. */
22
+ const MAX_TITLE_LENGTH = 96;
23
+ /** A card shows at most two live-claim buttons and two state badges; the rest fold into overflow. */
24
+ const MAX_ACTIVE_CHIPS = 2;
25
+ const MAX_BADGE_CHIPS = 2;
26
+
27
+ /**
28
+ * @param {unknown} value
29
+ * @returns {value is string}
30
+ */
31
+ function isString(value) {
32
+ return typeof value === "string";
33
+ }
34
+
35
+ /**
36
+ * AK timestamps are RFC 3339 with offset. Unparseable or non-positive values are rejected so an
37
+ * unprovable lease can never be presented as live custody.
38
+ * @param {unknown} value
39
+ * @returns {number | null}
40
+ */
41
+ export function parseAkTimestampMs(value) {
42
+ if (!isString(value) || !value.trim()) return null;
43
+ const parsed = Date.parse(value.trim());
44
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
45
+ }
46
+
47
+ /**
48
+ * @param {unknown} value
49
+ * @returns {number | null} positive safe integer task id, or null
50
+ */
51
+ function parseTaskId(value) {
52
+ const id = Number(value);
53
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} value
58
+ * @returns {string} bounded title
59
+ */
60
+ function boundTitle(value) {
61
+ return isString(value) ? value.trim().slice(0, MAX_TITLE_LENGTH) : "";
62
+ }
63
+
64
+ /**
65
+ * Normalize a repo path for prefix matching. Only absolute paths can join; anything else would
66
+ * invent a relationship between a card and a task.
67
+ * @param {unknown} value
68
+ * @returns {string | null}
69
+ */
70
+ function normalizeRepoPath(value) {
71
+ if (!isString(value)) return null;
72
+ const trimmed = value.trim().replace(/\/+$/, "");
73
+ return trimmed.startsWith("/") ? trimmed : null;
74
+ }
75
+
76
+ /**
77
+ * True when a session working directory sits inside the task's registered repo.
78
+ * @param {unknown} cardCwd
79
+ * @param {string} taskRepo
80
+ */
81
+ export function cwdIsInsideRepo(cardCwd, taskRepo) {
82
+ if (!isString(cardCwd)) return false;
83
+ const cwd = cardCwd.trim().replace(/\/+$/, "");
84
+ return cwd.length > 0 && (cwd === taskRepo || cwd.startsWith(`${taskRepo}/`));
85
+ }
86
+
87
+ /**
88
+ * Parse `ak task list --format json --verbose` output into raw claim rows. Rows without a
89
+ * session-bound claim or a parseable lease are skipped; anything malformed at the envelope level
90
+ * fails the whole parse so callers can drop chips entirely.
91
+ * @param {string} raw
92
+ * @returns {{ok: true; claims: import("./contracts.ts").AkTaskClaim[]} | {ok: false; error: string}}
93
+ */
94
+ export function parseAkClaimList(raw) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(String(raw ?? ""));
98
+ } catch (error) {
99
+ return {
100
+ ok: false,
101
+ error: `AK task list is not JSON: ${error instanceof Error ? error.message : String(error)}`,
102
+ };
103
+ }
104
+ if (!Array.isArray(parsed)) {
105
+ return { ok: false, error: "AK task list did not return a JSON array." };
106
+ }
107
+ /** @type {import("./contracts.ts").AkTaskClaim[]} */
108
+ const claims = [];
109
+ for (const entry of parsed) {
110
+ if (!entry || typeof entry !== "object") continue;
111
+ const record = /** @type {Record<string, unknown>} */ (entry);
112
+ const id = parseTaskId(record.id);
113
+ const claimedBy = isString(record.claimed_by) ? record.claimed_by.trim() : "";
114
+ const match = SESSION_CLAIM_PATTERN.exec(claimedBy);
115
+ const leaseExpiresAt = parseAkTimestampMs(record.lease_expires_at);
116
+ const claimedAt = parseAkTimestampMs(record.claimed_at);
117
+ const repo = normalizeRepoPath(record.repo);
118
+ const title = boundTitle(record.title);
119
+ if (id === null || !match || leaseExpiresAt === null || !repo || !title) continue;
120
+ if (record.status !== "claimed") continue;
121
+ claims.push({
122
+ id,
123
+ title,
124
+ repo,
125
+ sessionId: match[1].toLowerCase(),
126
+ leaseExpiresAt,
127
+ claimedAt: claimedAt ?? 0,
128
+ });
129
+ }
130
+ return { ok: true, claims };
131
+ }
132
+
133
+ /**
134
+ * Parse `ak task deferred --format json` output. Only rows whose deferral is still active join.
135
+ * @param {string} raw
136
+ * @returns {{ok: true; deferred: import("./contracts.ts").AkTaskDeferred[]} | {ok: false; error: string}}
137
+ */
138
+ export function parseAkDeferredList(raw) {
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(String(raw ?? ""));
142
+ } catch (error) {
143
+ return {
144
+ ok: false,
145
+ error: `AK deferred list is not JSON: ${error instanceof Error ? error.message : String(error)}`,
146
+ };
147
+ }
148
+ if (!Array.isArray(parsed)) {
149
+ return { ok: false, error: "AK deferred list did not return a JSON array." };
150
+ }
151
+ /** @type {import("./contracts.ts").AkTaskDeferred[]} */
152
+ const deferred = [];
153
+ for (const entry of parsed) {
154
+ if (!entry || typeof entry !== "object") continue;
155
+ const record = /** @type {Record<string, unknown>} */ (entry);
156
+ const task = record.task;
157
+ const deferral = record.deferral;
158
+ if (!task || typeof task !== "object" || !deferral || typeof deferral !== "object") continue;
159
+ const taskRecord = /** @type {Record<string, unknown>} */ (task);
160
+ const deferralRecord = /** @type {Record<string, unknown>} */ (deferral);
161
+ const id = parseTaskId(taskRecord.id);
162
+ const repo = normalizeRepoPath(taskRecord.repo);
163
+ const title = boundTitle(taskRecord.title);
164
+ if (id === null || !repo || !title) continue;
165
+ if (deferralRecord.state !== "active") continue;
166
+ deferred.push({ id, title, repo });
167
+ }
168
+ return { ok: true, deferred };
169
+ }
170
+
171
+ /**
172
+ * AK5700: an expired lease is vacant custody, so the claim no longer names an active owner.
173
+ * @param {{leaseExpiresAt: number}} claim
174
+ * @param {number} nowMs
175
+ */
176
+ export function claimIsLive(claim, nowMs) {
177
+ return claim.leaseExpiresAt > nowMs;
178
+ }
179
+
180
+ /**
181
+ * Cards whose session holds a live claim get clickable task references. A claim is ambiguous when
182
+ * the claiming session id backs more than one card (one logical session resumed into two
183
+ * terminals); ambiguous claims bind nothing. A task that also carries an active deferral never
184
+ * becomes a button: the deferred badge state wins.
185
+ * @param {Array<Record<string, unknown>>} cards
186
+ * @param {import("./contracts.ts").AkTaskClaim[]} claims
187
+ * @param {Set<number>} deferredIds
188
+ * @param {number} nowMs
189
+ * @returns {Map<string, import("./contracts.ts").AkTaskClaim[]>} active claims per cardId
190
+ */
191
+ function activeClaimsByCardId(cards, claims, deferredIds, nowMs) {
192
+ /** @type {Map<string, Set<string>>} */
193
+ const sessionIdToCardIds = new Map();
194
+ for (const card of cards) {
195
+ const cardId = String(card.cardId ?? "");
196
+ for (const sessionId of cardSessionIds(card)) {
197
+ const cardIds = sessionIdToCardIds.get(sessionId) ?? new Set();
198
+ cardIds.add(cardId);
199
+ sessionIdToCardIds.set(sessionId, cardIds);
200
+ }
201
+ }
202
+ /** @type {Map<string, import("./contracts.ts").AkTaskClaim[]>} */
203
+ const byCardId = new Map();
204
+ for (const card of cards) {
205
+ const cardId = String(card.cardId ?? "");
206
+ byCardId.set(
207
+ cardId,
208
+ claims
209
+ .filter((claim) => claimIsLive(claim, nowMs))
210
+ .filter((claim) => !deferredIds.has(claim.id))
211
+ .filter((claim) => {
212
+ const cardIds = sessionIdToCardIds.get(claim.sessionId);
213
+ return cardIds?.size === 1 && cardIds.has(cardId);
214
+ })
215
+ .sort(compareClaimsNewestFirst),
216
+ );
217
+ }
218
+ return byCardId;
219
+ }
220
+
221
+ /**
222
+ * @param {Record<string, unknown>} card
223
+ * @returns {string[]}
224
+ */
225
+ function cardSessionIds(card) {
226
+ const ids = Array.isArray(card.publisherSessionIds) ? card.publisherSessionIds : [];
227
+ return [...new Set(ids.map((value) => String(value ?? "").trim()).filter(Boolean))];
228
+ }
229
+
230
+ /** @param {import("./contracts.ts").AkTaskClaim} left @param {import("./contracts.ts").AkTaskClaim} right */
231
+ function compareClaimsNewestFirst(left, right) {
232
+ if (left.claimedAt !== right.claimedAt) return right.claimedAt - left.claimedAt;
233
+ if (left.leaseExpiresAt !== right.leaseExpiresAt)
234
+ return right.leaseExpiresAt - left.leaseExpiresAt;
235
+ return left.id - right.id;
236
+ }
237
+
238
+ /** @param {import("./contracts.ts").AkTaskClaim} left @param {import("./contracts.ts").AkTaskClaim} right */
239
+ function compareOrphansSoonestVacantFirst(left, right) {
240
+ if (left.leaseExpiresAt !== right.leaseExpiresAt)
241
+ return left.leaseExpiresAt - right.leaseExpiresAt;
242
+ return left.id - right.id;
243
+ }
244
+
245
+ /**
246
+ * Join AK task references onto projected cards.
247
+ *
248
+ * - `active` chips: the card's own session holds a live claim (exact session-id join, unique).
249
+ * - `orphaned` badges: a live-lease claim by a session with no live card, joined by repo. Custody
250
+ * has not lapsed yet, but the owner is gone, so the fact is a badge rather than a button.
251
+ * - `deferred` badges: tasks with an active deferral, joined by repo.
252
+ *
253
+ * @param {{
254
+ * cards: Array<Record<string, unknown>>;
255
+ * claims?: import("./contracts.ts").AkTaskClaim[];
256
+ * deferred?: import("./contracts.ts").AkTaskDeferred[];
257
+ * liveSessionIds?: Set<string> | string[];
258
+ * nowMs: number;
259
+ * }} options
260
+ * @returns {{cards: Array<Record<string, unknown>>; orphanedClaimCount: number; renderedCounts: {active: number; orphaned: number; deferred: number}}}
261
+ */
262
+ export function joinAkTaskChips({
263
+ cards,
264
+ claims = [],
265
+ deferred = [],
266
+ liveSessionIds = new Set(),
267
+ nowMs,
268
+ }) {
269
+ const liveIds =
270
+ liveSessionIds instanceof Set ? liveSessionIds : new Set([...liveSessionIds].map(String));
271
+ const deferredIds = new Set(deferred.map((task) => task.id));
272
+ const byCardId = activeClaimsByCardId(cards, claims, deferredIds, nowMs);
273
+
274
+ const liveClaims = claims.filter((claim) => claimIsLive(claim, nowMs));
275
+ const orphanedClaims = liveClaims.filter((claim) => !liveIds.has(claim.sessionId));
276
+ const orphanedIds = new Set(orphanedClaims.map((claim) => claim.id));
277
+
278
+ /** @type {number} */
279
+ let renderedActive = 0;
280
+ /** @type {number} */
281
+ let renderedOrphaned = 0;
282
+ /** @type {number} */
283
+ let renderedDeferred = 0;
284
+
285
+ const joined = cards.map((card) => {
286
+ const cardActive = byCardId.get(String(card.cardId ?? "")) ?? [];
287
+ const active = cardActive.slice(0, MAX_ACTIVE_CHIPS);
288
+ const activeOverflow = Math.max(0, cardActive.length - active.length);
289
+ const activeIds = new Set(cardActive.map((claim) => claim.id));
290
+ const cwd = String(card.cwd ?? "");
291
+
292
+ const orphaned = orphanedClaims
293
+ .filter((claim) => cwdIsInsideRepo(cwd, claim.repo))
294
+ .sort(compareOrphansSoonestVacantFirst);
295
+ const deferredForCard = deferred
296
+ .filter((task) => cwdIsInsideRepo(cwd, task.repo) && !orphanedIds.has(task.id))
297
+ .filter((task) => !activeIds.has(task.id))
298
+ .sort((left, right) => left.id - right.id);
299
+ const badges = [...orphaned, ...deferredForCard].slice(0, MAX_BADGE_CHIPS);
300
+ const badgeOverflow = Math.max(0, orphaned.length + deferredForCard.length - badges.length);
301
+
302
+ const chips = [
303
+ ...active.map((claim) => ({ id: claim.id, title: claim.title, state: AK_TASK_CHIP_ACTIVE })),
304
+ ...badges.map((task) => ({
305
+ id: task.id,
306
+ title: task.title,
307
+ state: orphanedIds.has(task.id) ? AK_TASK_CHIP_ORPHANED : AK_TASK_CHIP_DEFERRED,
308
+ })),
309
+ ];
310
+ renderedActive += active.length;
311
+ renderedOrphaned += badges.filter((task) => orphanedIds.has(task.id)).length;
312
+ renderedDeferred += badges.filter((task) => !orphanedIds.has(task.id)).length;
313
+
314
+ const next = { ...card };
315
+ if (chips.length > 0) next.akTasks = chips;
316
+ if (activeOverflow + badgeOverflow > 0) next.akTaskOverflow = activeOverflow + badgeOverflow;
317
+ return next;
318
+ });
319
+
320
+ return {
321
+ cards: joined,
322
+ orphanedClaimCount: orphanedClaims.length,
323
+ renderedCounts: {
324
+ active: renderedActive,
325
+ orphaned: renderedOrphaned,
326
+ deferred: renderedDeferred,
327
+ },
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Convenience summary for the status surface.
333
+ * @param {{
334
+ * claims?: import("./contracts.ts").AkTaskClaim[];
335
+ * deferred?: import("./contracts.ts").AkTaskDeferred[];
336
+ * liveSessionIds?: Set<string> | string[];
337
+ * nowMs: number;
338
+ * }} options
339
+ */
340
+ export function summarizeAkTasks({
341
+ claims = [],
342
+ deferred = [],
343
+ liveSessionIds = new Set(),
344
+ nowMs,
345
+ }) {
346
+ const liveIds =
347
+ liveSessionIds instanceof Set ? liveSessionIds : new Set([...liveSessionIds].map(String));
348
+ const liveClaims = claims.filter((claim) => claimIsLive(claim, nowMs));
349
+ return {
350
+ liveClaimCount: liveClaims.length,
351
+ orphanedClaimCount: liveClaims.filter((claim) => !liveIds.has(claim.sessionId)).length,
352
+ deferredCount: deferred.length,
353
+ };
354
+ }
@@ -1,35 +1,9 @@
1
1
  // ---
2
- // summary: "coalesces native-window alignment so stale async work cannot overwrite newer geometry"
2
+ // summary: "coalesces asynchronous reconciliation so stale work cannot publish over newer state"
3
3
  // read_when:
4
- // - "changing Electron/Niri geometry reconciliation or testing rapid expansion transitions"
4
+ // - "changing latest-only native workspace reconciliation"
5
5
  // ---
6
6
 
7
- /**
8
- * @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown } } | null | undefined} window
9
- */
10
- export function hasNiriFloatingPosition(window) {
11
- const position = window?.layout?.tile_pos_in_workspace_view;
12
- return window?.is_floating === true && Array.isArray(position) && position.length >= 2;
13
- }
14
-
15
- /**
16
- * @param {{ is_floating?: boolean, layout?: { tile_pos_in_workspace_view?: unknown, window_size?: unknown } } | null | undefined} window
17
- * @param {{x: number, y: number, width: number, height: number}} target
18
- */
19
- export function isNiriWindowAligned(window, target) {
20
- if (!hasNiriFloatingPosition(window)) return false;
21
- const position = window?.layout?.tile_pos_in_workspace_view;
22
- const size = window?.layout?.window_size;
23
- return Boolean(
24
- Array.isArray(position) &&
25
- Array.isArray(size) &&
26
- Number(size[0]) === target.width &&
27
- Number(size[1]) === target.height &&
28
- Math.abs(Number(position[0]) - target.x) < 1 &&
29
- Math.abs(Number(position[1]) - target.y) < 1,
30
- );
31
- }
32
-
33
7
  /**
34
8
  * @param {(attempt: { generation: number, isCurrent: () => boolean }) => Promise<void>} run
35
9
  */
@@ -37,34 +11,42 @@ export function createLatestOnlyRunner(run) {
37
11
  if (typeof run !== "function") throw new TypeError("run must be a function");
38
12
 
39
13
  let requestedGeneration = 0;
14
+ let completedGeneration = 0;
40
15
  /** @type {Promise<void> | null} */
41
16
  let worker = null;
42
17
 
18
+ function ensureWorker() {
19
+ if (!worker) worker = drain();
20
+ }
21
+
43
22
  async function drain() {
44
- while (true) {
45
- const generation = requestedGeneration;
46
- const isCurrent = () => generation === requestedGeneration;
47
- try {
48
- await run({ generation, isCurrent });
49
- } catch {
50
- // Alignment is best effort. A newer request must still be allowed to reconcile the window.
23
+ try {
24
+ while (completedGeneration < requestedGeneration) {
25
+ const generation = requestedGeneration;
26
+ const isCurrent = () => generation === requestedGeneration;
27
+ try {
28
+ await run({ generation, isCurrent });
29
+ } catch {
30
+ // Alignment is best effort. A newer request must still be allowed to reconcile the window.
31
+ }
32
+ completedGeneration = generation;
51
33
  }
52
- if (isCurrent()) return;
34
+ } finally {
35
+ worker = null;
36
+ // A request can arrive after the loop condition was checked but before this finally block.
37
+ // Hand it to a successor synchronously so no requested generation is lost.
38
+ if (completedGeneration < requestedGeneration) ensureWorker();
53
39
  }
54
40
  }
55
41
 
56
42
  function request() {
57
43
  requestedGeneration += 1;
58
- if (!worker) {
59
- worker = drain().finally(() => {
60
- worker = null;
61
- });
62
- }
44
+ ensureWorker();
63
45
  return requestedGeneration;
64
46
  }
65
47
 
66
48
  async function waitForIdle() {
67
- await worker;
49
+ while (worker) await worker;
68
50
  }
69
51
 
70
52
  return {
@@ -46,3 +46,8 @@ export function disambiguatedRepoLabel(session, duplicateLabels) {
46
46
  ? `${label} · ${String(processId).slice(-4)}`
47
47
  : label;
48
48
  }
49
+
50
+ /** @param {{hovered?: boolean; activeElement?: boolean; documentFocused?: boolean}} state */
51
+ export function shouldRetainExpandedCard({ hovered, activeElement, documentFocused }) {
52
+ return Boolean(hovered || (documentFocused && activeElement));
53
+ }
@@ -0,0 +1,190 @@
1
+ // ---
2
+ // summary: "maps Claude Code hook payloads to activity-strip state and stores one record per session"
3
+ // read_when:
4
+ // - "changing Claude Code hook events, their state mapping, or the event record on disk"
5
+ // ---
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { ACTIVITY_STRIP_SOCKET_DIR } from "./constants.mjs";
10
+
11
+ export const CLAUDE_EVENT_SCHEMA = "pi-activity-strip-claude-event.v1";
12
+ export const CLAUDE_EVENT_DIR = path.join(ACTIVITY_STRIP_SOCKET_DIR, "claude-events");
13
+ /** Hook events older than this are ignored; the transcript remains the fallback. */
14
+ export const CLAUDE_EVENT_MAX_AGE_MS = 6 * 60 * 60_000;
15
+ /**
16
+ * A session that dies without firing `SessionEnd` leaves its record behind. Such a record can never
17
+ * produce a card, because a card needs a live process, but the files would accumulate forever. They
18
+ * are retired once no live session claims them and they have stopped being recent, so a session
19
+ * that publishes before the scan first sees it is never swept away mid-startup.
20
+ */
21
+ export const CLAUDE_EVENT_ORPHAN_GRACE_MS = 10 * 60_000;
22
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+ const DETAIL_LIMIT = 100;
24
+
25
+ /** @typedef {"idle" | "thinking" | "tool" | "waiting" | "error"} ClaudeEventState */
26
+
27
+ /** @param {unknown} value */
28
+ function preview(value) {
29
+ const text = String(value ?? "")
30
+ .replace(/\s+/g, " ")
31
+ .trim();
32
+ return text.length > DETAIL_LIMIT ? `${text.slice(0, DETAIL_LIMIT - 1)}…` : text;
33
+ }
34
+
35
+ /** @param {unknown} sessionId */
36
+ export function claudeEventPath(sessionId, directory = CLAUDE_EVENT_DIR) {
37
+ const normalized = String(sessionId ?? "")
38
+ .trim()
39
+ .toLowerCase();
40
+ return SESSION_ID.test(normalized) ? path.join(directory, `${normalized}.json`) : "";
41
+ }
42
+
43
+ /**
44
+ * Name what a tool call is acting on, preferring fields that read well on a card.
45
+ * @param {Record<string, unknown>} toolInput
46
+ */
47
+ export function describeHookToolTarget(toolInput) {
48
+ for (const key of ["description", "command", "file_path", "path", "pattern", "query", "url"]) {
49
+ const value = /** @type {any} */ (toolInput ?? {})[key];
50
+ if (typeof value === "string" && value.trim()) return preview(value);
51
+ }
52
+ return "";
53
+ }
54
+
55
+ /**
56
+ * Translate one hook payload into the record the ribbon stores. `null` means the event carries no
57
+ * usable identity and must be dropped rather than guessed.
58
+ * @param {Record<string, unknown>} payload
59
+ * @param {{now?: number; env?: NodeJS.ProcessEnv}} [options]
60
+ */
61
+ export function claudeEventRecord(payload, { now = Date.now(), env = process.env } = {}) {
62
+ const sessionId = String(payload?.session_id ?? "")
63
+ .trim()
64
+ .toLowerCase();
65
+ if (!SESSION_ID.test(sessionId)) return null;
66
+ const event = String(payload?.hook_event_name ?? "").trim();
67
+ if (!event) return null;
68
+
69
+ const toolName = String(payload?.tool_name ?? "").trim();
70
+ const toolInput = /** @type {Record<string, unknown>} */ (payload?.tool_input ?? {});
71
+ const notification = String(payload?.notification_type ?? "").trim();
72
+
73
+ /** @type {ClaudeEventState} */
74
+ let state = "thinking";
75
+ let detail = "";
76
+ if (event === "PreToolUse") {
77
+ state = "tool";
78
+ detail = describeHookToolTarget(toolInput);
79
+ } else if (event === "PostToolUseFailure") {
80
+ state = "error";
81
+ detail = `${toolName || "tool"} failed`;
82
+ } else if (event === "PostToolUse") {
83
+ state = "thinking";
84
+ } else if (event === "Notification") {
85
+ state = "waiting";
86
+ detail =
87
+ notification === "permission_prompt"
88
+ ? "waiting for approval"
89
+ : notification === "idle_prompt" || notification === "agent_needs_input"
90
+ ? "waiting for input"
91
+ : preview(notification) || "waiting";
92
+ } else if (event === "Stop" || event === "SubagentStop") {
93
+ state = "idle";
94
+ detail = preview(payload?.last_assistant_message);
95
+ } else if (event === "UserPromptSubmit") {
96
+ state = "thinking";
97
+ } else if (event === "SessionStart") {
98
+ state = "idle";
99
+ } else if (event === "SessionEnd") {
100
+ state = "idle";
101
+ }
102
+
103
+ return {
104
+ schema: CLAUDE_EVENT_SCHEMA,
105
+ sessionId,
106
+ event,
107
+ state,
108
+ toolName: state === "tool" || state === "error" ? toolName : "",
109
+ toolTarget: state === "tool" ? detail : "",
110
+ detail,
111
+ notificationType: notification,
112
+ cwd: String(payload?.cwd ?? ""),
113
+ transcriptPath: String(payload?.transcript_path ?? ""),
114
+ surfaceId: String(env.GHOSTTY_SURFACE_ID ?? ""),
115
+ processId: Number(env.PI_ACTIVITY_STRIP_HOOK_PID ?? 0) || 0,
116
+ at: now,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * `SessionEnd` retires a session, so its record is removed rather than left behind.
122
+ * @param {{event?: unknown} | null} record
123
+ */
124
+ export function isSessionEndEvent(record) {
125
+ return record?.event === "SessionEnd";
126
+ }
127
+
128
+ /**
129
+ * Fold a stored hook record into transcript telemetry. The newer of the two wins, so a live hook
130
+ * event overrides a stale transcript read and a transcript that moved after the last hook still
131
+ * shows current work.
132
+ * @param {import("./claude-transcript.mjs").ClaudeTelemetry} telemetry
133
+ * @param {ReturnType<typeof claudeEventRecord>} record
134
+ * @param {number} [now]
135
+ */
136
+ export function mergeClaudeEvent(telemetry, record, now = Date.now()) {
137
+ if (!record || record.schema !== CLAUDE_EVENT_SCHEMA) return telemetry;
138
+ const at = Number(record.at ?? 0);
139
+ if (!Number.isFinite(at) || at <= 0 || now - at > CLAUDE_EVENT_MAX_AGE_MS) return telemetry;
140
+ if (at <= Number(telemetry?.lastEventAt ?? 0)) return telemetry;
141
+ return {
142
+ ...telemetry,
143
+ state: /** @type {any} */ (record.state),
144
+ toolName: String(record.toolName ?? ""),
145
+ toolTarget: String(record.toolTarget ?? ""),
146
+ assistantPreview: record.detail || telemetry.assistantPreview,
147
+ lastEventAt: at,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Remove hook records that no live session claims. Returns the retired session ids.
153
+ * @param {Iterable<string>} activeSessionIds
154
+ * @param {{fs?: Pick<typeof fs, "readdirSync" | "readFileSync" | "unlinkSync">; directory?: string; now?: number}} [options]
155
+ */
156
+ export function pruneClaudeEventRecords(
157
+ activeSessionIds,
158
+ { fs: fsImpl = fs, directory = CLAUDE_EVENT_DIR, now = Date.now() } = {},
159
+ ) {
160
+ const active = new Set(activeSessionIds ?? []);
161
+ /** @type {string[]} */
162
+ const retired = [];
163
+ let entries;
164
+ try {
165
+ entries = fsImpl.readdirSync(directory);
166
+ } catch {
167
+ return retired;
168
+ }
169
+ for (const entry of entries) {
170
+ if (!entry.endsWith(".json")) continue;
171
+ const sessionId = entry.slice(0, -".json".length);
172
+ if (active.has(sessionId)) continue;
173
+ let publishedAt = 0;
174
+ try {
175
+ publishedAt = Number(
176
+ JSON.parse(String(fsImpl.readFileSync(path.join(directory, entry), "utf8"))).at ?? 0,
177
+ );
178
+ } catch {
179
+ publishedAt = 0;
180
+ }
181
+ if (publishedAt > 0 && now - publishedAt < CLAUDE_EVENT_ORPHAN_GRACE_MS) continue;
182
+ try {
183
+ fsImpl.unlinkSync(path.join(directory, entry));
184
+ retired.push(sessionId);
185
+ } catch {
186
+ // Another writer removed it first.
187
+ }
188
+ }
189
+ return retired;
190
+ }