@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,161 @@
1
+ // ---
2
+ // summary: "projects tracked sessions onto one Niri workspace as cards with known visibility"
3
+ // read_when:
4
+ // - "changing workspace card membership, card visibility, or focused-card selection"
5
+ // ---
6
+
7
+ import {
8
+ canVerifyTabVisibility,
9
+ placeSessionWindow,
10
+ resolvePiSessionIdentity,
11
+ } from "./niri-focus.mjs";
12
+ import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
13
+ import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
14
+ import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
15
+
16
+ /** @typedef {import("./window-placement.mjs").PlacementOptions} PlacementOptions */
17
+ /**
18
+ * @typedef {PlacementOptions & {
19
+ * env?: NodeJS.ProcessEnv;
20
+ * readFileSync?: typeof import("node:fs").readFileSync;
21
+ * existsSync?: typeof import("node:fs").existsSync;
22
+ * }} ResolveOptions
23
+ */
24
+
25
+ /**
26
+ * Decide whether a placed tab is the one its window is currently showing. A window title naming a
27
+ * different terminal surface, or missing a title this session is known to set, is proof the tab is
28
+ * hidden. Without either signal visibility is unknown, and an unknown tab is never marked hidden:
29
+ * an agent that sets no recognizable title would otherwise look permanently buried.
30
+ * @param {Record<string, unknown>} window
31
+ * @param {Record<string, unknown>} session
32
+ * @param {string} placement
33
+ * @param {ResolveOptions} [options]
34
+ * @returns {"visible" | "hidden" | "unknown"}
35
+ */
36
+ export function resolveSurfaceVisibility(window, session, placement, options = {}) {
37
+ if (placement === "title") return "visible";
38
+ // A session that writes its own identity into the terminal title was already matched against
39
+ // every window title. Reaching here means no title matched, so this tab is not the one shown.
40
+ if (canVerifyTabVisibility(session, options)) return "hidden";
41
+ const terminalKey = canonicalGhosttyTerminalKey(session);
42
+ const titled = parseTerminalTitleBinding(window?.title);
43
+ if (titled && terminalKey && titled.terminalKey !== terminalKey) return "hidden";
44
+ return "unknown";
45
+ }
46
+
47
+ /**
48
+ * Project the global broker snapshot onto one exact Niri workspace. Membership requires an exact
49
+ * window placement: a visible title, or a hidden tab attributed through its Ghostty host process.
50
+ * Multiple publisher streams bound to the same terminal are aggregated into one stable card; a
51
+ * window may carry several terminal cards because each tab is its own surface, but a card claimed
52
+ * by distinct windows still fails closed. Only a visible tab in the focused window is "current".
53
+ * @param {Array<Record<string, unknown>>} windows
54
+ * @param {Record<string, unknown>} workspace
55
+ * @param {Array<Record<string, unknown>>} sessions
56
+ * @param {ResolveOptions} [options]
57
+ */
58
+ export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
59
+ if (!Number.isInteger(workspace?.id)) return null;
60
+ /** @type {Array<{cardId: string; session: Record<string, unknown>; window: Record<string, unknown>; placement: string}>} */
61
+ const candidates = [];
62
+ for (const session of sessions) {
63
+ const placed = placeSessionWindow(windows, session, options);
64
+ if (!placed || placed.window.workspace_id !== workspace.id) continue;
65
+ const cardId = sessionCardId(session);
66
+ if (!cardId) continue;
67
+ candidates.push({
68
+ cardId,
69
+ session: { ...session, cardId },
70
+ window: placed.window,
71
+ placement: placed.placement,
72
+ });
73
+ }
74
+
75
+ /** @type {Map<unknown, Set<string>>} */
76
+ const cardIdsByWindow = new Map();
77
+ for (const candidate of candidates) {
78
+ const ids = cardIdsByWindow.get(candidate.window.id) ?? new Set();
79
+ ids.add(candidate.cardId);
80
+ cardIdsByWindow.set(candidate.window.id, ids);
81
+ }
82
+ /** @type {Map<unknown, Set<string>>} */
83
+ const allowedCardsByWindow = new Map();
84
+ for (const [windowId, ids] of cardIdsByWindow) {
85
+ const terminalIds = [...ids].filter((cardId) => cardId.startsWith("terminal:"));
86
+ if (terminalIds.length > 0) allowedCardsByWindow.set(windowId, new Set(terminalIds));
87
+ else if (ids.size === 1) allowedCardsByWindow.set(windowId, ids);
88
+ }
89
+
90
+ /** @type {Map<string, Array<{session: Record<string, unknown>; window: Record<string, unknown>; placement: string}>>} */
91
+ const groups = new Map();
92
+ for (const candidate of candidates) {
93
+ if (!allowedCardsByWindow.get(candidate.window.id)?.has(candidate.cardId)) continue;
94
+ const group = groups.get(candidate.cardId) ?? [];
95
+ group.push(candidate);
96
+ groups.set(candidate.cardId, group);
97
+ }
98
+
99
+ const projectedSessions = [];
100
+ for (const [cardId, group] of groups) {
101
+ const windowIds = new Set(group.map((candidate) => candidate.window.id));
102
+ if (windowIds.size !== 1) continue;
103
+ const [card] = projectSessionCards(group.map((candidate) => candidate.session));
104
+ if (!card) continue;
105
+ const window = group[0]?.window;
106
+ const placement = group.some((candidate) => candidate.placement === "title")
107
+ ? "title"
108
+ : (group[0]?.placement ?? "title");
109
+ const visibility = resolveSurfaceVisibility(
110
+ window,
111
+ group[0]?.session ?? card,
112
+ placement,
113
+ options,
114
+ );
115
+ projectedSessions.push({
116
+ ...card,
117
+ cardId,
118
+ windowId: window?.id,
119
+ placement,
120
+ // Only a tab proven to sit behind another one is marked hidden.
121
+ surfaceVisible: visibility !== "hidden",
122
+ });
123
+ }
124
+ // The focused window shows exactly one tab. Several candidates mean the compositor cannot tell
125
+ // us which, so nothing is marked current rather than marking the wrong one.
126
+ const focusedCandidates = projectedSessions.filter(
127
+ (session) =>
128
+ session.surfaceVisible &&
129
+ windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
130
+ );
131
+ const focusedCard = focusedCandidates.length === 1 ? focusedCandidates[0] : null;
132
+ return {
133
+ workspace,
134
+ sessions: projectedSessions,
135
+ focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
136
+ focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Project the global broker snapshot onto the one focused Niri workspace.
142
+ * @param {Array<Record<string, unknown>>} windows
143
+ * @param {Array<Record<string, unknown>>} workspaces
144
+ * @param {Array<Record<string, unknown>>} sessions
145
+ * @param {ResolveOptions} [options]
146
+ */
147
+ export function resolveFocusedWorkspaceView(windows, workspaces, sessions, options = {}) {
148
+ const workspace = resolveFocusedNiriWorkspace(workspaces);
149
+ return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
150
+ }
151
+
152
+ /** @param {Array<Record<string, unknown>>} workspaces */
153
+ export function resolveFocusedNiriWorkspace(workspaces) {
154
+ const matches = workspaces.filter(
155
+ (workspace) =>
156
+ workspace?.is_focused === true &&
157
+ Number.isInteger(workspace?.id) &&
158
+ (typeof workspace?.name === "string" || Number.isInteger(workspace?.idx)),
159
+ );
160
+ return matches.length === 1 ? matches[0] : null;
161
+ }
@@ -0,0 +1,294 @@
1
+ // ---
2
+ // summary: "scans procfs for terminal-based agent tabs and reads the Claude Code title that identifies their window"
3
+ // read_when:
4
+ // - "changing agent tab discovery, its procfs evidence, or the Claude Code title adapter"
5
+ // ---
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import {
11
+ agentSessionRecord,
12
+ classifyAgentProcess,
13
+ isRuntimeCommand,
14
+ } from "../common/agent-identity.mjs";
15
+ import { claudeEventPath, mergeClaudeEvent } from "../common/claude-events.mjs";
16
+ import { parseClaudeTranscriptTail } from "../common/claude-transcript.mjs";
17
+ import { parseCodexRolloutTail } from "../common/codex-transcript.mjs";
18
+ import {
19
+ classifyGhosttyFamily,
20
+ findGhosttyAncestor,
21
+ normalizeGhosttySurfaceId,
22
+ readProcessStartTime,
23
+ } from "../common/terminal-identity.mjs";
24
+ import {
25
+ bindCodexThread,
26
+ openRolloutPaths,
27
+ readCodexThreads,
28
+ resolveCodexStateDb,
29
+ } from "./codex-discovery.mjs";
30
+
31
+ const CLOCK_TICKS_PER_SECOND = 100;
32
+ const CLAUDE_SCRATCHPAD =
33
+ /\/claude-\d+\/([^/]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/;
34
+ const CLAUDE_TITLE_TAIL_BYTES = 256 * 1024;
35
+ /** A title short enough to collide with an unrelated window is never used for placement. */
36
+ const MIN_PLACEABLE_TITLE_LENGTH = 6;
37
+ const TELEMETRY_CACHE_LIMIT = 64;
38
+ /** Parsed transcripts keyed by path and modification time, so an idle session is parsed once. */
39
+ const telemetryCache = new Map();
40
+
41
+ /** @typedef {{readFile: (filePath: string) => string; readLink: (filePath: string) => string; readDir: (filePath: string) => string[]; statMtimeMs: (filePath: string) => number; readTail: (filePath: string, bytes: number) => string}} Procfs */
42
+
43
+ /** @type {Procfs} */
44
+ export const nodeProcfs = {
45
+ readFile(filePath) {
46
+ try {
47
+ return fs.readFileSync(filePath, "utf8");
48
+ } catch {
49
+ return "";
50
+ }
51
+ },
52
+ readLink(filePath) {
53
+ try {
54
+ return fs.readlinkSync(filePath);
55
+ } catch {
56
+ return "";
57
+ }
58
+ },
59
+ readDir(filePath) {
60
+ try {
61
+ return fs.readdirSync(filePath);
62
+ } catch {
63
+ return [];
64
+ }
65
+ },
66
+ statMtimeMs(filePath) {
67
+ try {
68
+ return fs.statSync(filePath).mtimeMs;
69
+ } catch {
70
+ return 0;
71
+ }
72
+ },
73
+ readTail(filePath, bytes) {
74
+ let handle;
75
+ try {
76
+ handle = fs.openSync(filePath, "r");
77
+ const size = fs.fstatSync(handle).size;
78
+ const length = Math.min(size, bytes);
79
+ const buffer = Buffer.alloc(length);
80
+ fs.readSync(handle, buffer, 0, length, Math.max(0, size - length));
81
+ return buffer.toString("utf8");
82
+ } catch {
83
+ return "";
84
+ } finally {
85
+ if (handle !== undefined) {
86
+ try {
87
+ fs.closeSync(handle);
88
+ } catch {
89
+ // The descriptor is already gone.
90
+ }
91
+ }
92
+ }
93
+ },
94
+ };
95
+
96
+ /** @param {string} raw */
97
+ function parseEnviron(raw) {
98
+ /** @type {Record<string, string>} */
99
+ const env = {};
100
+ for (const entry of raw.split("\0")) {
101
+ const index = entry.indexOf("=");
102
+ if (index > 0) env[entry.slice(0, index)] = entry.slice(index + 1);
103
+ }
104
+ return env;
105
+ }
106
+
107
+ /**
108
+ * Read live session state from the tail of a Claude Code transcript. Results are cached against the
109
+ * file's modification time, so a session that is not writing costs one stat call per scan.
110
+ * @param {string} transcriptPath
111
+ * @param {Procfs} procfs
112
+ * @param {Map<string, {mtime: number; telemetry: import("../common/claude-transcript.mjs").ClaudeTelemetry}>} [cache]
113
+ */
114
+ export function readClaudeTelemetry(transcriptPath, procfs = nodeProcfs, cache = telemetryCache) {
115
+ const mtime = procfs.statMtimeMs(transcriptPath);
116
+ const cached = cache.get(transcriptPath);
117
+ if (cached && cached.mtime === mtime) return cached.telemetry;
118
+ const telemetry = parseClaudeTranscriptTail(
119
+ procfs.readTail(transcriptPath, CLAUDE_TITLE_TAIL_BYTES),
120
+ );
121
+ if (mtime > 0) {
122
+ if (cache.size >= TELEMETRY_CACHE_LIMIT) cache.clear();
123
+ cache.set(transcriptPath, { mtime, telemetry });
124
+ }
125
+ return telemetry;
126
+ }
127
+
128
+ /**
129
+ * Live state for one Codex tab. The thread index names the session and its rollout; the rollout
130
+ * tail reports what that session is doing now.
131
+ * @param {{processId: number; cwd: string; startedAt: number}} process
132
+ * @param {import("./codex-discovery.mjs").CodexThread[]} threads
133
+ * @param {Procfs} procfs
134
+ * @param {Map<string, any>} cache
135
+ */
136
+ export function readCodexSession(process, threads, procfs, cache) {
137
+ const thread = bindCodexThread(
138
+ { ...process, openPaths: openRolloutPaths(process.processId, procfs) },
139
+ threads,
140
+ );
141
+ if (!thread) return null;
142
+ const mtime = procfs.statMtimeMs(thread.rolloutPath);
143
+ const cached = cache.get(thread.rolloutPath);
144
+ const telemetry =
145
+ cached && cached.mtime === mtime
146
+ ? cached.telemetry
147
+ : parseCodexRolloutTail(procfs.readTail(thread.rolloutPath, CLAUDE_TITLE_TAIL_BYTES));
148
+ if (mtime > 0 && (!cached || cached.mtime !== mtime)) {
149
+ if (cache.size >= TELEMETRY_CACHE_LIMIT) cache.clear();
150
+ cache.set(thread.rolloutPath, { mtime, telemetry });
151
+ }
152
+ return { thread, telemetry: { ...telemetry, title: thread.title || telemetry.title } };
153
+ }
154
+
155
+ /**
156
+ * Read the newest hook record a Claude Code session published, if any. Hooks are the documented,
157
+ * real-time source; the transcript remains the fallback for sessions started before hooks existed.
158
+ * @param {string} sessionId
159
+ * @param {Procfs} procfs
160
+ */
161
+ export function readClaudeHookEvent(sessionId, procfs = nodeProcfs) {
162
+ const filePath = claudeEventPath(sessionId);
163
+ if (!filePath) return null;
164
+ try {
165
+ const parsed = JSON.parse(procfs.readFile(filePath));
166
+ return parsed && typeof parsed === "object" ? parsed : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Claude Code opens its per-session scratchpad, whose path carries the encoded working directory
174
+ * and the session id. That is exact process-bound evidence of which conversation a tab is running.
175
+ * @param {number} processId
176
+ * @param {Procfs} procfs
177
+ * @param {string} [homeDir]
178
+ */
179
+ export function resolveClaudeSession(processId, procfs = nodeProcfs, homeDir = os.homedir()) {
180
+ const fdDir = path.join("/proc", String(processId), "fd");
181
+ for (const entry of procfs.readDir(fdDir)) {
182
+ const target = procfs.readLink(path.join(fdDir, entry));
183
+ const match = target.match(CLAUDE_SCRATCHPAD);
184
+ if (!match) continue;
185
+ const [, encodedCwd, sessionId] = match;
186
+ const transcript = path.join(homeDir, ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
187
+ return { sessionId, encodedCwd, transcript };
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /** @param {Procfs} procfs */
193
+ function bootTimeMs(procfs) {
194
+ const uptimeSeconds = Number.parseFloat(procfs.readFile("/proc/uptime").split(/\s+/)[0] ?? "");
195
+ return Number.isFinite(uptimeSeconds) ? Date.now() - uptimeSeconds * 1000 : 0;
196
+ }
197
+
198
+ /**
199
+ * Discover every Ghostty tab running a recognized agent. A tab is admitted only with a bounded
200
+ * Ghostty surface id, a live Ghostty ancestor of a known build family, and a recognized executable.
201
+ * @param {{procfs?: Procfs; env?: NodeJS.ProcessEnv; homeDir?: string; now?: () => number; telemetryCache?: Map<string, any>}} [options]
202
+ */
203
+ export function discoverAgentTabs(options = {}) {
204
+ const {
205
+ procfs = nodeProcfs,
206
+ env = process.env,
207
+ homeDir = os.homedir(),
208
+ now = Date.now,
209
+ telemetryCache: cache = telemetryCache,
210
+ } = options;
211
+ const boot = bootTimeMs(procfs);
212
+ // The Codex thread index is read once per scan and only when a Codex tab is actually present.
213
+ /** @type {import("./codex-discovery.mjs").CodexThread[] | null} */
214
+ let codexThreads = null;
215
+ const codexHome = String(env.CODEX_HOME ?? "").trim() || path.join(homeDir, ".codex");
216
+ const records = [];
217
+ for (const entry of procfs.readDir("/proc")) {
218
+ if (!/^\d+$/.test(entry)) continue;
219
+ const processId = Number(entry);
220
+ // The executable name is one tiny read, so it gates the far more expensive reads below. An
221
+ // agent started through a runtime reports that runtime as its name, so those names — and only
222
+ // those — are worth the second read of the command line.
223
+ const command = procfs.readFile(path.join("/proc", entry, "comm")).trim();
224
+ let agent = classifyAgentProcess({ command, env });
225
+ if (!agent && (!command || isRuntimeCommand(command))) {
226
+ const argv = procfs.readFile(path.join("/proc", entry, "cmdline")).split("\0");
227
+ agent = classifyAgentProcess({ command, argv0: argv[0], argv1: argv[1], env });
228
+ }
229
+ if (!agent) continue;
230
+ const processEnv = parseEnviron(procfs.readFile(path.join("/proc", entry, "environ")));
231
+ if (String(processEnv.TERM_PROGRAM ?? "").toLowerCase() !== "ghostty") continue;
232
+ const surfaceId = normalizeGhosttySurfaceId(processEnv.GHOSTTY_SURFACE_ID);
233
+ if (!surfaceId) continue;
234
+ const ancestor = findGhosttyAncestor(processId, procfs);
235
+ const family = classifyGhosttyFamily(ancestor?.executable);
236
+ if (!family) continue;
237
+
238
+ const startTicks = Number(readProcessStartTime(processId, procfs) || 0);
239
+ const startedAt =
240
+ boot > 0 && startTicks > 0 ? boot + (startTicks / CLOCK_TICKS_PER_SECOND) * 1000 : now();
241
+ const cwd = procfs.readLink(path.join("/proc", entry, "cwd"));
242
+ let sessionKey = "";
243
+ let titleSuffix = "";
244
+ let lastEventAt = 0;
245
+ /** @type {import("../common/claude-transcript.mjs").ClaudeTelemetry | null} */
246
+ let telemetry = null;
247
+ if (agent.kind === "codex") {
248
+ if (codexThreads === null) codexThreads = readCodexThreads(resolveCodexStateDb(codexHome));
249
+ const session = readCodexSession({ processId, cwd, startedAt }, codexThreads, procfs, cache);
250
+ if (session) {
251
+ sessionKey = session.thread.id;
252
+ telemetry = session.telemetry;
253
+ lastEventAt =
254
+ session.telemetry.lastEventAt || procfs.statMtimeMs(session.thread.rolloutPath);
255
+ }
256
+ } else if (agent.kind === "claude") {
257
+ const claude = resolveClaudeSession(processId, procfs, homeDir);
258
+ if (claude) {
259
+ sessionKey = claude.sessionId;
260
+ telemetry = mergeClaudeEvent(
261
+ readClaudeTelemetry(claude.transcript, procfs, cache),
262
+ readClaudeHookEvent(claude.sessionId, procfs),
263
+ now(),
264
+ );
265
+ titleSuffix = telemetry.title;
266
+
267
+ // The transcript's own newest timestamp is the true activity clock; its modification time
268
+ // also moves for the latched records Claude Code rewrites without one.
269
+ lastEventAt = telemetry.lastEventAt || procfs.statMtimeMs(claude.transcript);
270
+ }
271
+ }
272
+ const discoveredTitle = String(telemetry?.title ?? "");
273
+ if (discoveredTitle.length < MIN_PLACEABLE_TITLE_LENGTH) titleSuffix = "";
274
+ else if (!titleSuffix) titleSuffix = discoveredTitle;
275
+ records.push(
276
+ agentSessionRecord({
277
+ kind: agent.kind,
278
+ label: agent.label,
279
+ processId,
280
+ cwd,
281
+ terminalKey: `ghostty:${family}:${surfaceId}`,
282
+ terminalFamily: family,
283
+ terminalSurfaceId: surfaceId,
284
+ sessionKey,
285
+ titleSuffix,
286
+ startedAt,
287
+ lastEventAt,
288
+ now: now(),
289
+ telemetry,
290
+ }),
291
+ );
292
+ }
293
+ return records;
294
+ }
@@ -0,0 +1,164 @@
1
+ // ---
2
+ // summary: "polls read-only ak CLI output and hands parsed claims to the card projection"
3
+ // read_when:
4
+ // - "changing AK polling cadence, fail-closed behavior, or the ak CLI read surface"
5
+ // ---
6
+
7
+ import {
8
+ claimIsLive,
9
+ parseAkClaimList,
10
+ parseAkDeferredList,
11
+ summarizeAkTasks,
12
+ } from "../common/ak-tasks.mjs";
13
+
14
+ /** AK state changes are rare; a calm clock keeps the ribbon cheap. */
15
+ export const AK_TASK_REFRESH_MS = 15_000;
16
+ const AK_EXEC_TIMEOUT_MS = 8000;
17
+ const AK_EXEC_MAX_BUFFER = 4 * 1024 * 1024;
18
+
19
+ /**
20
+ * The strip reads AK strictly through the read-only CLI (`ak task list` / `ak task deferred`);
21
+ * it never opens the society database and never writes. Any failure (missing binary, non-zero
22
+ * exit, malformed output) fails closed: chips are dropped rather than guessed.
23
+ * @param {{
24
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
25
+ * env?: NodeJS.ProcessEnv;
26
+ * now?: () => number;
27
+ * runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
28
+ * onChange: (state: {claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]}) => void;
29
+ * }} options
30
+ */
31
+ export function createAkTaskRuntime({
32
+ execFileAsync,
33
+ env = process.env,
34
+ now = Date.now,
35
+ runtimeStatus,
36
+ onChange,
37
+ }) {
38
+ const disabled = env.PI_ACTIVITY_STRIP_AK_TASKS === "0";
39
+ const akBinary = env.PI_ACTIVITY_STRIP_AK_BIN?.trim() || "ak";
40
+ /** @type {NodeJS.Timeout | null} */
41
+ let timer = null;
42
+ let inFlight = false;
43
+
44
+ runtimeStatus.akTaskState = disabled ? "disabled" : (runtimeStatus.akTaskState ?? "pending");
45
+ runtimeStatus.akTaskError = runtimeStatus.akTaskError ?? null;
46
+ runtimeStatus.akTaskClaimCount = runtimeStatus.akTaskClaimCount ?? 0;
47
+ runtimeStatus.akTaskDeferredCount = runtimeStatus.akTaskDeferredCount ?? 0;
48
+ runtimeStatus.akTaskOrphanCount = runtimeStatus.akTaskOrphanCount ?? 0;
49
+ runtimeStatus.akTaskRenderedCount = runtimeStatus.akTaskRenderedCount ?? 0;
50
+
51
+ /**
52
+ * @param {string[]} args
53
+ * @returns {Promise<string>}
54
+ */
55
+ async function readCli(args) {
56
+ const { stdout } = await execFileAsync(akBinary, args, {
57
+ timeout: AK_EXEC_TIMEOUT_MS,
58
+ maxBuffer: AK_EXEC_MAX_BUFFER,
59
+ });
60
+ return String(stdout ?? "");
61
+ }
62
+
63
+ /**
64
+ * @param {string} error
65
+ */
66
+ function failClosed(error) {
67
+ runtimeStatus.akTaskState = "unavailable";
68
+ runtimeStatus.akTaskError = error;
69
+ runtimeStatus.akTaskClaimCount = 0;
70
+ runtimeStatus.akTaskDeferredCount = 0;
71
+ onChange({ claims: [], deferred: [] });
72
+ }
73
+
74
+ async function refresh() {
75
+ if (disabled || inFlight) return false;
76
+ inFlight = true;
77
+ try {
78
+ const [claimRaw, deferredRaw] = await Promise.all([
79
+ readCli(["task", "list", "--status", "claimed", "--format", "json", "--all", "--verbose"]),
80
+ readCli(["task", "deferred", "--format", "json", "--all"]),
81
+ ]);
82
+ const claims = parseAkClaimList(claimRaw);
83
+ const deferred = parseAkDeferredList(deferredRaw);
84
+ if (!claims.ok) {
85
+ failClosed(claims.error);
86
+ return false;
87
+ }
88
+ if (!deferred.ok) {
89
+ failClosed(deferred.error);
90
+ return false;
91
+ }
92
+ runtimeStatus.akTaskState = "ok";
93
+ runtimeStatus.akTaskError = null;
94
+ runtimeStatus.akTaskClaimCount = claims.claims.filter((claim) =>
95
+ claimIsLive(claim, now()),
96
+ ).length;
97
+ runtimeStatus.akTaskDeferredCount = deferred.deferred.length;
98
+ onChange({ claims: claims.claims, deferred: deferred.deferred });
99
+ return true;
100
+ } catch (error) {
101
+ failClosed(`ak CLI unavailable: ${error instanceof Error ? error.message : String(error)}`);
102
+ return false;
103
+ } finally {
104
+ inFlight = false;
105
+ }
106
+ }
107
+
108
+ return {
109
+ refresh,
110
+ get disabled() {
111
+ return disabled;
112
+ },
113
+ start() {
114
+ if (disabled || timer) return;
115
+ void refresh();
116
+ timer = setInterval(() => void refresh(), AK_TASK_REFRESH_MS);
117
+ timer.unref?.();
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Wire the AK read loop into the card projection: parsed claims become chips, and orphaned
124
+ * custody is counted against every live session so a claim is only orphaned when no live
125
+ * session anywhere still holds it.
126
+ * @param {{
127
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
128
+ * env?: NodeJS.ProcessEnv;
129
+ * runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
130
+ * projection: {
131
+ * setAkTasks: (state: {claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null) => void;
132
+ * getRawSessions: () => Array<Record<string, unknown>>;
133
+ * };
134
+ * }} options
135
+ */
136
+ export function startAkTaskProjection({
137
+ execFileAsync,
138
+ env = process.env,
139
+ runtimeStatus,
140
+ projection,
141
+ }) {
142
+ const runtime = createAkTaskRuntime({
143
+ execFileAsync,
144
+ env,
145
+ runtimeStatus,
146
+ onChange: (state) => {
147
+ projection.setAkTasks(state);
148
+ const summary = summarizeAkTasks({
149
+ claims: state.claims,
150
+ deferred: state.deferred,
151
+ liveSessionIds: new Set(
152
+ projection
153
+ .getRawSessions()
154
+ .map((session) => String(session.sessionId ?? ""))
155
+ .filter(Boolean),
156
+ ),
157
+ nowMs: Date.now(),
158
+ });
159
+ runtimeStatus.akTaskOrphanCount = summary.orphanedClaimCount;
160
+ },
161
+ });
162
+ runtime.start();
163
+ return runtime;
164
+ }