@tryinget/pi-activity-strip 0.5.0 → 0.7.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.
- package/README.md +59 -7
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +98 -64
- package/extensions/activity-strip.js +25 -21
- package/native/bin/linux-x64-gnu/artifact.json +2 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +20 -20
- package/native/panel/src/card_view.rs +396 -9
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +142 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +147 -60
- package/package.json +4 -4
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +13 -0
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +35 -0
- package/src/common/contracts.ts +67 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -111
- package/src/common/session-cards.mjs +5 -2
- package/src/common/status-report.mjs +51 -0
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/terminal-identity.mjs +75 -12
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +122 -64
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +70 -5
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- package/src/native/workspace-events.mjs +22 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "binds a running Codex process to its session thread and rollout file"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing Codex session identification or its thread-index reader"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const requireBuiltin = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Codex keeps an index of its sessions in a versioned SQLite database under its home, one row per
|
|
15
|
+
* thread with the rollout path, working directory and title. It only opens the rollout once a task
|
|
16
|
+
* runs, so the index is what links a live process to its session before any work starts.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const CODEX_STATE_DB = /^state_(\d+)\.sqlite$/;
|
|
20
|
+
export const CODEX_THREAD_LIMIT = 400;
|
|
21
|
+
/** A thread is created moments after its process starts; allow for clock and rounding slack. */
|
|
22
|
+
export const CODEX_THREAD_START_SLACK_MS = 5000;
|
|
23
|
+
|
|
24
|
+
/** @typedef {{id: string; rolloutPath: string; cwd: string; title: string; createdAtMs: number; updatedAtMs: number}} CodexThread */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Newest thread-index database in a Codex home. The suffix is a schema version, so the highest one
|
|
28
|
+
* is current; a home with none yields "".
|
|
29
|
+
* @param {string} codexHome
|
|
30
|
+
* @param {Pick<typeof fs, "readdirSync">} [fsImpl]
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCodexStateDb(codexHome, fsImpl = fs) {
|
|
33
|
+
let best = { version: -1, file: "" };
|
|
34
|
+
let entries = [];
|
|
35
|
+
try {
|
|
36
|
+
entries = fsImpl.readdirSync(codexHome);
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const match = CODEX_STATE_DB.exec(entry);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const version = Number.parseInt(match[1] ?? "", 10);
|
|
44
|
+
if (Number.isInteger(version) && version > best.version) best = { version, file: entry };
|
|
45
|
+
}
|
|
46
|
+
return best.file ? path.join(codexHome, best.file) : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the thread index. The database belongs to a running application, so it is opened read-only
|
|
51
|
+
* and every failure — a missing file, a locked database, or a runtime without `node:sqlite` —
|
|
52
|
+
* yields an empty index rather than an error.
|
|
53
|
+
* @param {string} databasePath
|
|
54
|
+
* @returns {CodexThread[]}
|
|
55
|
+
*/
|
|
56
|
+
export function readCodexThreads(databasePath) {
|
|
57
|
+
if (!databasePath) return [];
|
|
58
|
+
try {
|
|
59
|
+
// Loaded here so a runtime without the built-in SQLite module simply reports no threads.
|
|
60
|
+
const { DatabaseSync } = requireBuiltin("node:sqlite");
|
|
61
|
+
const db = new DatabaseSync(databasePath, { readOnly: true });
|
|
62
|
+
try {
|
|
63
|
+
const rows = db
|
|
64
|
+
.prepare(
|
|
65
|
+
"SELECT id, rollout_path, cwd, title, created_at_ms, updated_at_ms FROM threads WHERE archived = 0 ORDER BY created_at_ms DESC LIMIT ?",
|
|
66
|
+
)
|
|
67
|
+
.all(CODEX_THREAD_LIMIT);
|
|
68
|
+
return rows.map(
|
|
69
|
+
/** @param {any} row */ (row) => ({
|
|
70
|
+
id: String(row.id ?? ""),
|
|
71
|
+
rolloutPath: String(row.rollout_path ?? ""),
|
|
72
|
+
cwd: String(row.cwd ?? ""),
|
|
73
|
+
title: String(row.title ?? ""),
|
|
74
|
+
createdAtMs: Number(row.created_at_ms ?? 0),
|
|
75
|
+
updatedAtMs: Number(row.updated_at_ms ?? 0),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
} finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bind one Codex process to its thread. An open rollout descriptor is exact proof and wins. With
|
|
88
|
+
* no descriptor, a thread qualifies only when it was created after the process started and in the
|
|
89
|
+
* same directory; anything ambiguous binds nothing rather than guessing.
|
|
90
|
+
* @param {{processId: number; cwd: string; startedAt: number; openPaths?: string[]}} process
|
|
91
|
+
* @param {CodexThread[]} threads
|
|
92
|
+
* @returns {CodexThread | null}
|
|
93
|
+
*/
|
|
94
|
+
export function bindCodexThread({ cwd, startedAt, openPaths = [] }, threads) {
|
|
95
|
+
const open = new Set(openPaths.filter(Boolean));
|
|
96
|
+
if (open.size > 0) {
|
|
97
|
+
const byRollout = threads.filter(
|
|
98
|
+
(thread) => thread.rolloutPath && open.has(thread.rolloutPath),
|
|
99
|
+
);
|
|
100
|
+
if (byRollout.length === 1) return byRollout[0];
|
|
101
|
+
if (byRollout.length > 1) return null;
|
|
102
|
+
}
|
|
103
|
+
const normalizedCwd = String(cwd ?? "").trim();
|
|
104
|
+
if (!normalizedCwd || !Number.isFinite(startedAt) || startedAt <= 0) return null;
|
|
105
|
+
const candidates = threads.filter(
|
|
106
|
+
(thread) =>
|
|
107
|
+
thread.cwd === normalizedCwd &&
|
|
108
|
+
thread.createdAtMs > 0 &&
|
|
109
|
+
thread.createdAtMs >= startedAt - CODEX_THREAD_START_SLACK_MS,
|
|
110
|
+
);
|
|
111
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Rollout files a process currently holds open, which is exact evidence of the session it is
|
|
116
|
+
* running.
|
|
117
|
+
* @param {number} processId
|
|
118
|
+
* @param {{readDir: (path: string) => string[]; readLink: (path: string) => string}} procfs
|
|
119
|
+
*/
|
|
120
|
+
export function openRolloutPaths(processId, procfs) {
|
|
121
|
+
const fdDir = path.join("/proc", String(processId), "fd");
|
|
122
|
+
const paths = [];
|
|
123
|
+
for (const entry of procfs.readDir(fdDir)) {
|
|
124
|
+
const target = procfs.readLink(path.join(fdDir, entry));
|
|
125
|
+
if (target.includes("/sessions/") && target.endsWith(".jsonl")) paths.push(target);
|
|
126
|
+
}
|
|
127
|
+
return paths;
|
|
128
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ---
|
|
3
|
+
# summary: "enumerates Ghostty windows and their tab labels through AT-SPI for hidden-tab placement"
|
|
4
|
+
# read_when:
|
|
5
|
+
# - "changing how the activity strip discovers tabs hidden behind another Ghostty tab"
|
|
6
|
+
# ---
|
|
7
|
+
"""Print one JSON document describing every AT-SPI frame of the given process ids.
|
|
8
|
+
|
|
9
|
+
Usage: ghostty-tab-inventory.py <pid> [<pid> ...]
|
|
10
|
+
|
|
11
|
+
Output: {"ok": true, "frames": [{"pid": int, "name": str, "tabs": [str, ...]}, ...]}
|
|
12
|
+
Exit 3 with {"ok": false, "error": ...} when the AT-SPI Python bindings are unavailable.
|
|
13
|
+
The script only reads accessibility state; it never activates, selects, or focuses anything.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
MAX_NODES_PER_FRAME = 20000
|
|
20
|
+
MAX_DEPTH = 40
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
try:
|
|
25
|
+
import gi
|
|
26
|
+
|
|
27
|
+
gi.require_version("Atspi", "2.0")
|
|
28
|
+
from gi.repository import Atspi
|
|
29
|
+
except Exception as error: # noqa: BLE001 - any import failure means the capability is absent
|
|
30
|
+
print(json.dumps({"ok": False, "error": f"AT-SPI bindings unavailable: {error}"}))
|
|
31
|
+
return 3
|
|
32
|
+
|
|
33
|
+
pids = {int(argument) for argument in sys.argv[1:] if argument.isdigit()}
|
|
34
|
+
if not pids:
|
|
35
|
+
print(json.dumps({"ok": True, "frames": []}))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
def tab_labels(frame):
|
|
39
|
+
labels = []
|
|
40
|
+
stack = [(frame, 0)]
|
|
41
|
+
seen = 0
|
|
42
|
+
while stack and seen < MAX_NODES_PER_FRAME:
|
|
43
|
+
node, depth = stack.pop()
|
|
44
|
+
seen += 1
|
|
45
|
+
try:
|
|
46
|
+
role = node.get_role()
|
|
47
|
+
except Exception: # noqa: BLE001 - a vanished node is skipped
|
|
48
|
+
continue
|
|
49
|
+
if role == Atspi.Role.PAGE_TAB:
|
|
50
|
+
try:
|
|
51
|
+
labels.append(node.get_name() or "")
|
|
52
|
+
except Exception: # noqa: BLE001
|
|
53
|
+
pass
|
|
54
|
+
continue
|
|
55
|
+
if depth >= MAX_DEPTH:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
count = node.get_child_count()
|
|
59
|
+
except Exception: # noqa: BLE001
|
|
60
|
+
continue
|
|
61
|
+
for index in range(count):
|
|
62
|
+
try:
|
|
63
|
+
child = node.get_child_at_index(index)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
continue
|
|
66
|
+
if child is not None:
|
|
67
|
+
stack.append((child, depth + 1))
|
|
68
|
+
return labels
|
|
69
|
+
|
|
70
|
+
frames = []
|
|
71
|
+
desktop = Atspi.get_desktop(0)
|
|
72
|
+
for index in range(desktop.get_child_count()):
|
|
73
|
+
application = desktop.get_child_at_index(index)
|
|
74
|
+
if application is None:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
pid = application.get_process_id()
|
|
78
|
+
except Exception: # noqa: BLE001
|
|
79
|
+
continue
|
|
80
|
+
if pid not in pids:
|
|
81
|
+
continue
|
|
82
|
+
for window_index in range(application.get_child_count()):
|
|
83
|
+
frame = application.get_child_at_index(window_index)
|
|
84
|
+
if frame is None:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
if frame.get_role() != Atspi.Role.FRAME:
|
|
88
|
+
continue
|
|
89
|
+
name = frame.get_name() or ""
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
continue
|
|
92
|
+
frames.append({"pid": pid, "name": name, "tabs": tab_labels(frame)})
|
|
93
|
+
|
|
94
|
+
print(json.dumps({"ok": True, "frames": frames}))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|