@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,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())
@@ -0,0 +1,154 @@
1
+ // ---
2
+ // summary: "restores tiled windows stranded at the wrong height when the ribbon's exclusive zone appears or disappears"
3
+ // read_when:
4
+ // - "changing height repair, its evidence rules, or its safety bounds"
5
+ // ---
6
+
7
+ export const HEIGHT_REPAIR_SETTLE_MS = 300;
8
+ export const HEIGHT_REPAIR_MAX_PER_TRANSITION = 48;
9
+
10
+ /** @typedef {{id?: unknown; is_floating?: unknown; layout?: {window_size?: number[]}}} NiriWindow */
11
+ /** @typedef {{id: number; height: number}} StaleWindow */
12
+
13
+ /** @param {NiriWindow} window */
14
+ function tiledHeight(window) {
15
+ if (window?.is_floating === true) return 0;
16
+ const height = Number(window?.layout?.window_size?.[1] ?? 0);
17
+ return Number.isFinite(height) && height > 0 ? height : 0;
18
+ }
19
+
20
+ /** @param {NiriWindow[]} windows */
21
+ function heightsById(windows) {
22
+ /** @type {Map<number, number>} */
23
+ const heights = new Map();
24
+ for (const window of windows ?? []) {
25
+ if (!Number.isInteger(window?.id)) continue;
26
+ const height = tiledHeight(window);
27
+ if (height > 0) heights.set(Number(window.id), height);
28
+ }
29
+ return heights;
30
+ }
31
+
32
+ /**
33
+ * Decide which windows the compositor left behind. Changing the exclusive zone changes the output
34
+ * working area, so every window whose height is automatic moves. A window that did not move while
35
+ * others did is only treated as stale when it still sits at a height those movers just vacated:
36
+ * that is positive evidence of the old working area, so a deliberately chosen height of any other
37
+ * value is never touched.
38
+ * @param {NiriWindow[]} before
39
+ * @param {NiriWindow[]} after
40
+ * @returns {StaleWindow[]}
41
+ */
42
+ export function findStaleWindows(before, after) {
43
+ const previous = heightsById(before);
44
+ const current = heightsById(after);
45
+ /** @type {Set<number>} */
46
+ const vacated = new Set();
47
+ for (const [id, height] of current) {
48
+ const wasHeight = previous.get(id);
49
+ if (wasHeight !== undefined && wasHeight !== height) vacated.add(wasHeight);
50
+ }
51
+ if (vacated.size === 0) return [];
52
+
53
+ /** @type {StaleWindow[]} */
54
+ const stale = [];
55
+ for (const [id, height] of current) {
56
+ const wasHeight = previous.get(id);
57
+ if (wasHeight === undefined || wasHeight !== height) continue;
58
+ if (vacated.has(height)) stale.push({ id, height });
59
+ }
60
+ return stale.sort((left, right) => left.id - right.id);
61
+ }
62
+
63
+ /**
64
+ * @param {{
65
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
66
+ * env?: NodeJS.ProcessEnv;
67
+ * runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
68
+ * readWindows: () => Promise<NiriWindow[]>;
69
+ * sleep?: (milliseconds: number) => Promise<void>;
70
+ * settleMs?: number;
71
+ * }} options
72
+ */
73
+ export function createHeightRepair({
74
+ execFileAsync,
75
+ env = process.env,
76
+ runtimeStatus,
77
+ readWindows,
78
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
79
+ settleMs = HEIGHT_REPAIR_SETTLE_MS,
80
+ }) {
81
+ const enabled = env.PI_ACTIVITY_STRIP_HEIGHT_REPAIR !== "0";
82
+ /** @type {NiriWindow[]} */
83
+ let lastWindows = [];
84
+ /** Windows already reset at a given height, so one pass can never loop on one window. */
85
+ /** @type {Set<string>} */
86
+ const repaired = new Set();
87
+ let inFlight = false;
88
+ runtimeStatus.heightRepairState = enabled ? "enabled" : "disabled";
89
+ runtimeStatus.heightRepairCount = 0;
90
+ runtimeStatus.lastHeightRepairAt = 0;
91
+
92
+ /** @param {NiriWindow[]} windows */
93
+ function observe(windows) {
94
+ if (!Array.isArray(windows) || windows.length === 0) return;
95
+ // A window seen at a height other than the one it was reset at has moved since, so a future
96
+ // stranding there is a new event and must not be suppressed by the earlier repair.
97
+ for (const window of windows) {
98
+ if (!Number.isInteger(window?.id)) continue;
99
+ const height = tiledHeight(window);
100
+ if (height <= 0) continue;
101
+ for (const key of repaired) {
102
+ const [id, at] = key.split(":");
103
+ if (Number(id) === Number(window.id) && Number(at) !== height) repaired.delete(key);
104
+ }
105
+ }
106
+ lastWindows = windows;
107
+ }
108
+
109
+ /**
110
+ * Run after the panel reports that it applied a visibility change, which is the only moment the
111
+ * exclusive zone changes. One pass at a time; a pass never retries a window it already reset.
112
+ */
113
+ async function repairAfterZoneChange() {
114
+ if (!enabled || inFlight) return [];
115
+ const before = lastWindows;
116
+ if (before.length === 0) return [];
117
+ inFlight = true;
118
+ try {
119
+ await sleep(settleMs);
120
+ const after = await readWindows();
121
+ if (after.length === 0) return [];
122
+ lastWindows = after;
123
+ const stale = findStaleWindows(before, after).filter(
124
+ (window) => !repaired.has(`${window.id}:${window.height}`),
125
+ );
126
+ const bounded = stale.slice(0, HEIGHT_REPAIR_MAX_PER_TRANSITION);
127
+ /** @type {number[]} */
128
+ const applied = [];
129
+ for (const window of bounded) {
130
+ try {
131
+ await execFileAsync(
132
+ "niri",
133
+ ["msg", "action", "reset-window-height", "--id", String(window.id)],
134
+ { env },
135
+ );
136
+ repaired.add(`${window.id}:${window.height}`);
137
+ applied.push(window.id);
138
+ } catch {
139
+ // A window that closed mid-pass, or a rejected action, is left to the next transition.
140
+ }
141
+ }
142
+ if (applied.length > 0) {
143
+ runtimeStatus.heightRepairCount =
144
+ Number(runtimeStatus.heightRepairCount ?? 0) + applied.length;
145
+ runtimeStatus.lastHeightRepairAt = Date.now();
146
+ }
147
+ return applied;
148
+ } finally {
149
+ inFlight = false;
150
+ }
151
+ }
152
+
153
+ return { observe, repairAfterZoneChange, enabled };
154
+ }