@tryinget/pi-activity-strip 0.4.0 → 0.5.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 (40) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/Cargo.lock +1045 -0
  6. package/native/panel/Cargo.toml +23 -0
  7. package/native/panel/src/app.rs +606 -0
  8. package/native/panel/src/card_view.rs +303 -0
  9. package/native/panel/src/main.rs +31 -0
  10. package/native/panel/src/protocol.rs +234 -0
  11. package/native/panel/src/runtime.rs +110 -0
  12. package/native/panel/src/style.css +176 -0
  13. package/package.json +13 -1
  14. package/src/broker/server.mjs +43 -7
  15. package/src/broker/session-store.mjs +41 -13
  16. package/src/client/broker-client.mjs +10 -3
  17. package/src/client/session-telemetry.mjs +54 -14
  18. package/src/common/activity-order.mjs +15 -9
  19. package/src/common/alignment-controller.mjs +24 -42
  20. package/src/common/card-display.mjs +5 -0
  21. package/src/common/compatibility.mjs +15 -13
  22. package/src/common/constants.mjs +10 -28
  23. package/src/common/contracts.d.ts +42 -4
  24. package/src/common/contracts.ts +42 -4
  25. package/src/common/niri-focus.mjs +79 -79
  26. package/src/common/protocol.mjs +48 -16
  27. package/src/common/session-cards.mjs +102 -0
  28. package/src/common/status-report.mjs +2 -1
  29. package/src/common/telemetry.mjs +8 -1
  30. package/src/common/terminal-identity.mjs +149 -0
  31. package/src/native/main.mjs +393 -0
  32. package/src/native/panel-projection.mjs +99 -0
  33. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  34. package/src/common/electron.mjs +0 -57
  35. package/src/electron/main.mjs +0 -499
  36. package/src/electron/niri-native-window-runtime.mjs +0 -178
  37. package/src/electron/preload.cjs +0 -50
  38. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  39. package/src/electron/workspace-view-runtime.mjs +0 -360
  40. package/src/ui/strip-html.mjs +0 -500
@@ -5,6 +5,7 @@
5
5
  // ---
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
+ import { canonicalGhosttyTerminalKey, normalizeGhosttySurfaceId } from "./terminal-identity.mjs";
8
9
 
9
10
  /** @typedef {import("./contracts.ts").SessionState} SessionState */
10
11
  /** @typedef {import("./contracts.ts").SessionSnapshot} SessionSnapshot */
@@ -12,6 +13,21 @@ import { randomUUID } from "node:crypto";
12
13
  /** @type {Set<SessionState>} */
13
14
  const SESSION_STATES = new Set(["idle", "thinking", "tool", "waiting", "success", "error"]);
14
15
 
16
+ const MAX_CLOCK_SKEW_MS = 60_000;
17
+
18
+ /** @param {unknown} value @param {number} maxLength */
19
+ function boundedString(value, maxLength) {
20
+ return String(value ?? "").slice(0, maxLength);
21
+ }
22
+
23
+ /** @param {unknown} value @param {number} fallback @param {number} now */
24
+ function finiteTimestamp(value, fallback, now) {
25
+ const timestamp = Number(value);
26
+ return Number.isFinite(timestamp) && timestamp >= 0 && timestamp <= now + MAX_CLOCK_SKEW_MS
27
+ ? timestamp
28
+ : fallback;
29
+ }
30
+
15
31
  /** @param {string} type @param {Record<string, unknown>} [payload] */
16
32
  export function makeMessage(type, payload = {}) {
17
33
  return {
@@ -23,30 +39,46 @@ export function makeMessage(type, payload = {}) {
23
39
 
24
40
  /** @param {Partial<SessionSnapshot> | Record<string, unknown>} [session] @returns {SessionSnapshot} */
25
41
  export function normalizeSessionSnapshot(session = {}) {
42
+ if (!session || typeof session !== "object") session = {};
26
43
  const now = Date.now();
27
- const updatedAt = Number(session.updatedAt ?? now) || now;
28
- const lastEventAt = Number(session.lastEventAt ?? updatedAt) || updatedAt;
29
- const startedAt = Number(session.startedAt ?? updatedAt) || updatedAt;
44
+ const updatedAt = finiteTimestamp(session.updatedAt, now, now);
45
+ const lastEventAt = finiteTimestamp(session.lastEventAt, updatedAt, now);
46
+ const startedAt = finiteTimestamp(session.startedAt, updatedAt, now);
47
+ const normalizedAgentStartedAt = finiteTimestamp(session.agentStartedAt, 0, now);
30
48
  const agentStartedAt =
31
- session.agentStartedAt == null ? null : Number(session.agentStartedAt) || null;
49
+ session.agentStartedAt == null || !normalizedAgentStartedAt ? null : normalizedAgentStartedAt;
32
50
  const rawState = typeof session.state === "string" ? session.state : "idle";
33
51
  /** @type {SessionState} */
34
52
  const state = SESSION_STATES.has(/** @type {SessionState} */ (rawState))
35
53
  ? /** @type {SessionState} */ (rawState)
36
54
  : "idle";
37
55
 
56
+ const terminalKey = canonicalGhosttyTerminalKey(session);
57
+ const terminalKind = terminalKey ? "ghostty-surface" : "unbound";
58
+ const terminalFamily = terminalKey ? String(session.terminalFamily) : "";
59
+ const terminalSurfaceId = terminalKey ? normalizeGhosttySurfaceId(session.terminalSurfaceId) : "";
60
+ const rawPublisherSequence = Number(session.publisherSequence ?? 0);
61
+ const publisherSequence =
62
+ Number.isSafeInteger(rawPublisherSequence) && rawPublisherSequence >= 0
63
+ ? rawPublisherSequence
64
+ : 0;
38
65
  return {
39
- sessionId: String(session.sessionId ?? ""),
40
- publisherId: String(session.publisherId ?? ""),
66
+ sessionId: boundedString(session.sessionId, 256),
67
+ publisherId: boundedString(session.publisherId, 256),
68
+ publisherSequence,
41
69
  processId: Number(session.processId ?? 0) || 0,
42
- cwd: String(session.cwd ?? ""),
43
- repoLabel: String(session.repoLabel ?? "pi session"),
44
- sessionName: String(session.sessionName ?? ""),
45
- phase: String(session.phase ?? "Idle"),
46
- detail: String(session.detail ?? "Ready"),
47
- assistantPreview: String(session.assistantPreview ?? ""),
48
- toolName: String(session.toolName ?? ""),
49
- toolTarget: String(session.toolTarget ?? ""),
70
+ terminalKind,
71
+ terminalKey,
72
+ terminalFamily,
73
+ terminalSurfaceId,
74
+ cwd: boundedString(session.cwd, 4096),
75
+ repoLabel: boundedString(session.repoLabel ?? "pi session", 256),
76
+ sessionName: boundedString(session.sessionName, 256),
77
+ phase: boundedString(session.phase ?? "Idle", 512),
78
+ detail: boundedString(session.detail ?? "Ready", 2048),
79
+ assistantPreview: boundedString(session.assistantPreview, 2048),
80
+ toolName: boundedString(session.toolName, 256),
81
+ toolTarget: boundedString(session.toolTarget, 2048),
50
82
  state,
51
83
  turnIndex: Number(session.turnIndex ?? 0) || 0,
52
84
  updatedAt,
@@ -54,8 +86,8 @@ export function normalizeSessionSnapshot(session = {}) {
54
86
  startedAt,
55
87
  agentStartedAt,
56
88
  agentActive: Boolean(session.agentActive),
57
- lastPromptPreview: String(session.lastPromptPreview ?? ""),
58
- errorMessage: String(session.errorMessage ?? ""),
89
+ lastPromptPreview: boundedString(session.lastPromptPreview, 2048),
90
+ errorMessage: boundedString(session.errorMessage, 2048),
59
91
  };
60
92
  }
61
93
 
@@ -0,0 +1,102 @@
1
+ // ---
2
+ // summary: "projects publisher snapshots into stable operator-facing terminal cards"
3
+ // read_when:
4
+ // - "changing publisher aggregation, card identity, or membership comparison"
5
+ // ---
6
+
7
+ import { isActiveSession } from "./activity-order.mjs";
8
+ import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
9
+
10
+ /** @typedef {import("./contracts.ts").SessionSnapshot} SessionSnapshot */
11
+ /** @typedef {Partial<SessionSnapshot> & Record<string, unknown>} SessionLike */
12
+
13
+ /** @param {SessionLike} session */
14
+ export function sessionRecordKey(session) {
15
+ const sessionId = String(session?.sessionId ?? "");
16
+ const publisherId = String(session?.publisherId ?? "");
17
+ return publisherId ? `${sessionId}|${publisherId}` : sessionId;
18
+ }
19
+
20
+ /** @param {SessionLike} session */
21
+ export function sessionCardId(session) {
22
+ const terminalKey = canonicalGhosttyTerminalKey(session);
23
+ if (terminalKey) return `terminal:${terminalKey}`;
24
+ const sessionId = String(session?.sessionId ?? "").trim();
25
+ return sessionId ? `session:${sessionId}` : "";
26
+ }
27
+
28
+ /** @param {SessionLike} session */
29
+ export function sessionMembershipKey(session) {
30
+ return `${sessionRecordKey(session)}→${sessionCardId(session)}`;
31
+ }
32
+
33
+ /** @param {SessionLike} session */
34
+ function attentionRank(session) {
35
+ if (isActiveSession(session)) return 3;
36
+ if (session?.state === "error") return 2;
37
+ if (session?.state === "success") return 1;
38
+ return 0;
39
+ }
40
+
41
+ /** @param {SessionLike} left @param {SessionLike} right */
42
+ export function comparePublisherRepresentatives(left, right) {
43
+ const rankDelta = attentionRank(right) - attentionRank(left);
44
+ if (rankDelta) return rankDelta;
45
+ const eventDelta = Number(right?.lastEventAt ?? 0) - Number(left?.lastEventAt ?? 0);
46
+ if (eventDelta) return eventDelta;
47
+ const turnDelta = Number(right?.turnIndex ?? 0) - Number(left?.turnIndex ?? 0);
48
+ if (turnDelta) return turnDelta;
49
+ const startDelta = Number(right?.startedAt ?? 0) - Number(left?.startedAt ?? 0);
50
+ if (startDelta) return startDelta;
51
+ return sessionRecordKey(left).localeCompare(sessionRecordKey(right));
52
+ }
53
+
54
+ /**
55
+ * @param {SessionLike[]} [sessions]
56
+ * @param {Set<string> | null} [allowedCardIds]
57
+ * @returns {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]}>}
58
+ */
59
+ export function projectSessionCards(sessions = [], allowedCardIds = null) {
60
+ /** @type {Map<string, SessionLike[]>} */
61
+ const groups = new Map();
62
+ for (const session of sessions) {
63
+ const cardId = sessionCardId(session);
64
+ if (!cardId || (allowedCardIds && !allowedCardIds.has(cardId))) continue;
65
+ const group = groups.get(cardId) ?? [];
66
+ group.push(session);
67
+ groups.set(cardId, group);
68
+ }
69
+
70
+ /** @type {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]}>} */
71
+ const cards = [];
72
+ for (const [cardId, group] of groups) {
73
+ const ordered = [...group].sort(comparePublisherRepresentatives);
74
+ const representative = ordered[0];
75
+ if (!representative) continue;
76
+ cards.push({
77
+ ...representative,
78
+ cardId,
79
+ publisherCount: group.length,
80
+ publisherIds: group.map((session) => String(session.publisherId ?? "")).filter(Boolean),
81
+ publisherRecordKeys: group.map(sessionRecordKey),
82
+ });
83
+ }
84
+ return cards;
85
+ }
86
+
87
+ /** @param {SessionLike[]} [left] @param {SessionLike[]} [right] */
88
+ export function haveSameRecordMembership(left = [], right = []) {
89
+ if (left.length !== right.length) return false;
90
+ const leftKeys = left.map(sessionMembershipKey).sort();
91
+ const rightKeys = right.map(sessionMembershipKey).sort();
92
+ return leftKeys.every((key, index) => key === rightKeys[index]);
93
+ }
94
+
95
+ /** @param {SessionLike[]} [left] @param {SessionLike[]} [right] */
96
+ export function haveSameCardMembership(left = [], right = []) {
97
+ const leftKeys = [...new Set(left.map(sessionCardId).filter(Boolean))].sort();
98
+ const rightKeys = [...new Set(right.map(sessionCardId).filter(Boolean))].sort();
99
+ return (
100
+ leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index])
101
+ );
102
+ }
@@ -14,6 +14,7 @@ export function formatBrokerRuntimeStatus(result) {
14
14
 
15
15
  const lines = [
16
16
  runtime.state === "ready" ? "running (ready)" : `running (${runtime.state})`,
17
+ `backend: ${runtime.backend || "unknown"}`,
17
18
  `display: ${runtime.displayServer || "unknown"}`,
18
19
  `window manager: ${runtime.windowManager || "unknown"}`,
19
20
  `alignment: ${runtime.alignmentMode || "unknown"}`,
@@ -23,7 +24,7 @@ export function formatBrokerRuntimeStatus(result) {
23
24
  lines.push(`displays: ${runtime.displayCount}`);
24
25
  }
25
26
  if (typeof runtime.windowVisible === "boolean") {
26
- lines.push(`window visible: ${runtime.windowVisible ? "yes" : "no"}`);
27
+ lines.push(`surface visible: ${runtime.windowVisible ? "yes" : "no"}`);
27
28
  }
28
29
  if (runtime.error) {
29
30
  lines.push(`error: ${runtime.error}`);
@@ -67,18 +67,25 @@ export function createSessionId() {
67
67
  return `${os.hostname()}-${process.pid}-${Date.now()}-${randomUUID().slice(0, 6)}`;
68
68
  }
69
69
 
70
- /** @param {{ cwd?: string; sessionName?: string; sessionId?: string; publisherId?: string }} [options] @returns {SessionSnapshot} */
70
+ /** @param {{ cwd?: string; sessionName?: string; sessionId?: string; publisherId?: string; terminalIdentity?: {terminalKind?: string; terminalKey?: string; terminalFamily?: string; terminalSurfaceId?: string} }} [options] @returns {SessionSnapshot} */
71
71
  export function createInitialSnapshot({
72
72
  cwd = process.cwd(),
73
73
  sessionName = "",
74
74
  sessionId = "",
75
75
  publisherId = "",
76
+ terminalIdentity = {},
76
77
  } = {}) {
77
78
  const now = Date.now();
78
79
  return {
79
80
  sessionId: sessionId.trim() || createSessionId(),
80
81
  publisherId: publisherId.trim() || randomUUID(),
82
+ publisherSequence: 0,
81
83
  processId: process.pid,
84
+ terminalKind:
85
+ terminalIdentity.terminalKind === "ghostty-surface" ? "ghostty-surface" : "unbound",
86
+ terminalKey: String(terminalIdentity.terminalKey ?? ""),
87
+ terminalFamily: String(terminalIdentity.terminalFamily ?? ""),
88
+ terminalSurfaceId: String(terminalIdentity.terminalSurfaceId ?? ""),
82
89
  cwd,
83
90
  repoLabel: formatRepoLabel(cwd, sessionName),
84
91
  sessionName: compactWhitespace(sessionName),
@@ -0,0 +1,149 @@
1
+ // ---
2
+ // summary: "derives bounded terminal-surface identities for activity-strip publisher and focus contracts"
3
+ // read_when:
4
+ // - "changing terminal card identity, Ghostty surface admission, or title matching"
5
+ // ---
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const UINT64_MAX = 18_446_744_073_709_551_615n;
11
+ const GHOSTTY_MAIN_APP_ID = "com.mitchellh.ghostty";
12
+ const GHOSTTY_LEGACY_APP_ID = "com.tryinget.ghosttysidequest";
13
+
14
+ /** @typedef {{terminalKind: "ghostty-surface" | "unbound"; terminalKey: string; terminalFamily: string; terminalSurfaceId: string}} TerminalIdentity */
15
+ /** @typedef {{env?: NodeJS.ProcessEnv; hasUI?: boolean; stdinIsTTY?: boolean; ttyPath?: string; processId?: number; ancestorExecutable?: string}} TerminalIdentityOptions */
16
+
17
+ /** @param {string} filePath */
18
+ function safeReadFile(filePath) {
19
+ try {
20
+ return fs.readFileSync(filePath, "utf8");
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ /** @param {string} filePath */
27
+ function safeReadLink(filePath) {
28
+ try {
29
+ return fs.readlinkSync(filePath);
30
+ } catch {
31
+ return "";
32
+ }
33
+ }
34
+
35
+ /** @param {number} pid */
36
+ function readParentPid(pid) {
37
+ const value = safeReadFile(path.join("/proc", String(pid), "stat"));
38
+ const lastParen = value.lastIndexOf(")");
39
+ if (lastParen < 0) return 0;
40
+ const fields = value
41
+ .slice(lastParen + 2)
42
+ .trim()
43
+ .split(/\s+/);
44
+ const parentPid = Number.parseInt(fields[1] ?? "", 10);
45
+ return Number.isInteger(parentPid) && parentPid > 0 ? parentPid : 0;
46
+ }
47
+
48
+ /** @param {unknown} value */
49
+ export function normalizeGhosttySurfaceId(value) {
50
+ try {
51
+ const normalized = BigInt(String(value ?? "").trim());
52
+ return normalized >= 0n && normalized <= UINT64_MAX ? normalized.toString(10) : "";
53
+ } catch {
54
+ return "";
55
+ }
56
+ }
57
+
58
+ /** @param {unknown} executable */
59
+ export function classifyGhosttyFamily(executable) {
60
+ const normalized = String(executable ?? "").toLowerCase();
61
+ if (!normalized) return "";
62
+ if (normalized.includes("ghostty-sidequest")) return "legacy";
63
+ if (path.basename(normalized) === "ghostty" || normalized.includes("ghostty-origin"))
64
+ return "main";
65
+ return "";
66
+ }
67
+
68
+ /** @param {unknown} family */
69
+ export function appIdForGhosttyFamily(family) {
70
+ if (family === "main") return GHOSTTY_MAIN_APP_ID;
71
+ if (family === "legacy") return GHOSTTY_LEGACY_APP_ID;
72
+ return "";
73
+ }
74
+
75
+ /** @param {number} [processId] */
76
+ export function findGhosttyAncestorExecutable(processId = process.pid) {
77
+ let pid = Number(processId) || 0;
78
+ for (let depth = 0; depth < 12 && pid > 0; depth += 1) {
79
+ pid = readParentPid(pid);
80
+ if (pid <= 0) break;
81
+ const command = safeReadFile(path.join("/proc", String(pid), "comm"))
82
+ .trim()
83
+ .toLowerCase();
84
+ if (command === "ghostty") return safeReadLink(path.join("/proc", String(pid), "exe"));
85
+ }
86
+ return "";
87
+ }
88
+
89
+ /** @returns {TerminalIdentity} */
90
+ export function unboundTerminalIdentity() {
91
+ return {
92
+ terminalKind: "unbound",
93
+ terminalKey: "",
94
+ terminalFamily: "",
95
+ terminalSurfaceId: "",
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Admit a terminal binding only for an interactive TUI attached to a real TTY and a recognized
101
+ * Ghostty process family. Headless descendants commonly inherit Ghostty environment variables, so
102
+ * GHOSTTY_SURFACE_ID alone is not evidence of an operator-visible terminal.
103
+ * @param {TerminalIdentityOptions} [options]
104
+ * @returns {TerminalIdentity}
105
+ */
106
+ export function resolveTerminalIdentity({
107
+ env = process.env,
108
+ hasUI = false,
109
+ stdinIsTTY = Boolean(process.stdin.isTTY),
110
+ ttyPath = safeReadLink("/proc/self/fd/0"),
111
+ processId = process.pid,
112
+ ancestorExecutable,
113
+ } = {}) {
114
+ if (
115
+ !hasUI ||
116
+ !stdinIsTTY ||
117
+ !ttyPath.startsWith("/dev/") ||
118
+ String(env.TERM_PROGRAM ?? "").toLowerCase() !== "ghostty"
119
+ ) {
120
+ return unboundTerminalIdentity();
121
+ }
122
+ const surfaceId = normalizeGhosttySurfaceId(env.GHOSTTY_SURFACE_ID);
123
+ const executable = ancestorExecutable ?? findGhosttyAncestorExecutable(processId);
124
+ const family = classifyGhosttyFamily(executable);
125
+ if (!surfaceId || !family) return unboundTerminalIdentity();
126
+ return {
127
+ terminalKind: "ghostty-surface",
128
+ terminalKey: `ghostty:${family}:${surfaceId}`,
129
+ terminalFamily: family,
130
+ terminalSurfaceId: surfaceId,
131
+ };
132
+ }
133
+
134
+ /** @param {Partial<TerminalIdentity> | Record<string, unknown>} identity */
135
+ export function canonicalGhosttyTerminalKey(identity) {
136
+ if (identity?.terminalKind !== "ghostty-surface") return "";
137
+ const family = String(identity.terminalFamily ?? "");
138
+ if (family !== "main" && family !== "legacy") return "";
139
+ const surfaceId = normalizeGhosttySurfaceId(identity.terminalSurfaceId);
140
+ if (!surfaceId) return "";
141
+ const expectedKey = `ghostty:${family}:${surfaceId}`;
142
+ return String(identity.terminalKey ?? "") === expectedKey ? expectedKey : "";
143
+ }
144
+
145
+ /** @param {Partial<TerminalIdentity> | Record<string, unknown>} identity */
146
+ export function terminalTitleSegment(identity) {
147
+ if (!canonicalGhosttyTerminalKey(identity)) return "";
148
+ return `gs:${identity.terminalFamily}:${normalizeGhosttySurfaceId(identity.terminalSurfaceId)}`;
149
+ }