@tryinget/pi-activity-strip 0.3.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.
- package/README.md +96 -152
- package/bin/pi-activity-strip.mjs +31 -62
- package/examples/simulate-sessions.mjs +1 -1
- package/extensions/activity-strip.js +2 -2
- package/native/bin/linux-x64-gnu/artifact.json +21 -0
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/Cargo.lock +1045 -0
- package/native/panel/Cargo.toml +23 -0
- package/native/panel/src/app.rs +606 -0
- package/native/panel/src/card_view.rs +303 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +234 -0
- package/native/panel/src/runtime.rs +110 -0
- package/native/panel/src/style.css +176 -0
- package/package.json +20 -7
- package/src/broker/server.mjs +43 -7
- package/src/broker/session-store.mjs +53 -11
- package/src/client/broker-client.mjs +25 -4
- package/src/client/session-telemetry.mjs +123 -13
- package/src/common/activity-order.mjs +15 -9
- package/src/common/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +53 -0
- package/src/common/compatibility.mjs +15 -13
- package/src/common/constants.mjs +12 -28
- package/src/common/contracts.d.ts +54 -5
- package/src/common/contracts.ts +54 -5
- package/src/common/niri-focus.mjs +79 -79
- package/src/common/protocol.mjs +49 -14
- package/src/common/session-cards.mjs +102 -0
- package/src/common/status-report.mjs +2 -1
- package/src/common/telemetry.mjs +11 -1
- package/src/common/terminal-identity.mjs +149 -0
- package/src/native/main.mjs +393 -0
- package/src/native/panel-projection.mjs +99 -0
- package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
- package/src/common/electron.mjs +0 -57
- package/src/electron/main.mjs +0 -499
- package/src/electron/niri-native-window-runtime.mjs +0 -178
- package/src/electron/preload.cjs +0 -50
- package/src/electron/renderer-visibility-runtime.mjs +0 -107
- package/src/electron/workspace-view-runtime.mjs +0 -360
- package/src/ui/strip-html.mjs +0 -484
|
@@ -8,7 +8,17 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
|
|
|
8
8
|
|
|
9
9
|
export interface SessionSnapshot {
|
|
10
10
|
sessionId: string;
|
|
11
|
+
publisherId: string;
|
|
12
|
+
publisherSequence: number;
|
|
11
13
|
processId: number;
|
|
14
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
15
|
+
terminalKey: string;
|
|
16
|
+
terminalFamily: string;
|
|
17
|
+
terminalSurfaceId: string;
|
|
18
|
+
cardId?: string;
|
|
19
|
+
publisherCount?: number;
|
|
20
|
+
publisherIds?: string[];
|
|
21
|
+
publisherRecordKeys?: string[];
|
|
12
22
|
cwd: string;
|
|
13
23
|
repoLabel: string;
|
|
14
24
|
sessionName: string;
|
|
@@ -20,6 +30,7 @@ export interface SessionSnapshot {
|
|
|
20
30
|
state: SessionState;
|
|
21
31
|
turnIndex: number;
|
|
22
32
|
updatedAt: number;
|
|
33
|
+
lastEventAt: number;
|
|
23
34
|
startedAt: number;
|
|
24
35
|
agentStartedAt: number | null;
|
|
25
36
|
agentActive: boolean;
|
|
@@ -36,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
|
|
|
36
47
|
|
|
37
48
|
export interface ActivityStripRuntimeStatus {
|
|
38
49
|
state: ActivityStripRuntimeState;
|
|
50
|
+
backend?: "native-layer-shell";
|
|
39
51
|
startedAt: number;
|
|
40
52
|
readyAt?: number | null;
|
|
41
53
|
windowVisible?: boolean;
|
|
42
54
|
displayServer?: "wayland" | "x11" | "headless" | "unknown";
|
|
43
55
|
windowManager?: string | null;
|
|
44
56
|
displayCount?: number | null;
|
|
45
|
-
alignmentMode?: "
|
|
57
|
+
alignmentMode?: "layer-shell";
|
|
58
|
+
rendererCardCount?: number;
|
|
59
|
+
rendererCardIds?: string[];
|
|
60
|
+
rendererVisibilityTransitionCount?: number;
|
|
61
|
+
controllerPid?: number;
|
|
62
|
+
panelPid?: number | null;
|
|
63
|
+
panelExpanded?: boolean;
|
|
64
|
+
clickThrough?: boolean;
|
|
65
|
+
panelMoveCount?: number;
|
|
66
|
+
panelActivationCount?: number;
|
|
67
|
+
panelRestartCount?: number;
|
|
68
|
+
lastMovedCardId?: string | null;
|
|
46
69
|
warnings?: string[];
|
|
47
70
|
error?: string | null;
|
|
48
71
|
}
|
|
@@ -63,6 +86,7 @@ export interface BrokerClientOptions {
|
|
|
63
86
|
|
|
64
87
|
export interface SessionStoreOptions {
|
|
65
88
|
staleAfterMs?: number;
|
|
89
|
+
maxSessions?: number;
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
export interface ActivityStripBrokerOptions {
|
|
@@ -71,10 +95,11 @@ export interface ActivityStripBrokerOptions {
|
|
|
71
95
|
store?: {
|
|
72
96
|
snapshot(): BrokerSnapshot;
|
|
73
97
|
upsert(session: Partial<SessionSnapshot> | Record<string, unknown>): boolean;
|
|
74
|
-
remove(sessionId: string): boolean;
|
|
98
|
+
remove(sessionId: string, publisherId?: string): boolean;
|
|
75
99
|
};
|
|
76
100
|
getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
|
|
77
|
-
focusSession?: (
|
|
101
|
+
focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
|
|
102
|
+
focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
export interface ToolCallDescription {
|
|
@@ -115,6 +140,11 @@ export interface TurnStartEventLike {
|
|
|
115
140
|
turnIndex?: number;
|
|
116
141
|
}
|
|
117
142
|
|
|
143
|
+
export interface TurnEndEventLike {
|
|
144
|
+
turnIndex?: number;
|
|
145
|
+
message?: unknown;
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
export interface AssistantMessageEventLike {
|
|
119
149
|
type?: string;
|
|
120
150
|
delta?: unknown;
|
|
@@ -136,15 +166,34 @@ export interface SessionTelemetryOptions {
|
|
|
136
166
|
pi?: TelemetryPiLike;
|
|
137
167
|
cwd?: string;
|
|
138
168
|
sessionName?: string;
|
|
169
|
+
env?: NodeJS.ProcessEnv;
|
|
170
|
+
stdinIsTTY?: boolean;
|
|
171
|
+
resolveTerminalIdentity?: (options: {
|
|
172
|
+
env?: NodeJS.ProcessEnv;
|
|
173
|
+
hasUI?: boolean;
|
|
174
|
+
stdinIsTTY?: boolean;
|
|
175
|
+
ttyPath?: string;
|
|
176
|
+
processId?: number;
|
|
177
|
+
ancestorExecutable?: string;
|
|
178
|
+
}) => {
|
|
179
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
180
|
+
terminalKey: string;
|
|
181
|
+
terminalFamily: string;
|
|
182
|
+
terminalSurfaceId: string;
|
|
183
|
+
};
|
|
184
|
+
transport?: {
|
|
185
|
+
publish: (session: SessionSnapshot) => Promise<unknown>;
|
|
186
|
+
remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
|
|
187
|
+
};
|
|
139
188
|
}
|
|
140
189
|
|
|
141
190
|
export interface ActivityStripCompatibilityReport {
|
|
142
191
|
ok: boolean;
|
|
192
|
+
backend: string;
|
|
143
193
|
displayServer: "wayland" | "x11" | "headless" | "unknown";
|
|
144
194
|
windowManager: string | null;
|
|
145
|
-
electronPath: string | null;
|
|
146
195
|
displayCount: number | null;
|
|
147
|
-
alignmentMode: "
|
|
196
|
+
alignmentMode: "layer-shell";
|
|
148
197
|
primaryDisplayOnly: boolean;
|
|
149
198
|
clickThroughDefault: boolean;
|
|
150
199
|
blockers: string[];
|
package/src/common/contracts.ts
CHANGED
|
@@ -8,7 +8,17 @@ export type SessionState = "idle" | "thinking" | "tool" | "waiting" | "success"
|
|
|
8
8
|
|
|
9
9
|
export interface SessionSnapshot {
|
|
10
10
|
sessionId: string;
|
|
11
|
+
publisherId: string;
|
|
12
|
+
publisherSequence: number;
|
|
11
13
|
processId: number;
|
|
14
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
15
|
+
terminalKey: string;
|
|
16
|
+
terminalFamily: string;
|
|
17
|
+
terminalSurfaceId: string;
|
|
18
|
+
cardId?: string;
|
|
19
|
+
publisherCount?: number;
|
|
20
|
+
publisherIds?: string[];
|
|
21
|
+
publisherRecordKeys?: string[];
|
|
12
22
|
cwd: string;
|
|
13
23
|
repoLabel: string;
|
|
14
24
|
sessionName: string;
|
|
@@ -20,6 +30,7 @@ export interface SessionSnapshot {
|
|
|
20
30
|
state: SessionState;
|
|
21
31
|
turnIndex: number;
|
|
22
32
|
updatedAt: number;
|
|
33
|
+
lastEventAt: number;
|
|
23
34
|
startedAt: number;
|
|
24
35
|
agentStartedAt: number | null;
|
|
25
36
|
agentActive: boolean;
|
|
@@ -36,13 +47,25 @@ export type ActivityStripRuntimeState = "starting" | "ready" | "error";
|
|
|
36
47
|
|
|
37
48
|
export interface ActivityStripRuntimeStatus {
|
|
38
49
|
state: ActivityStripRuntimeState;
|
|
50
|
+
backend?: "native-layer-shell";
|
|
39
51
|
startedAt: number;
|
|
40
52
|
readyAt?: number | null;
|
|
41
53
|
windowVisible?: boolean;
|
|
42
54
|
displayServer?: "wayland" | "x11" | "headless" | "unknown";
|
|
43
55
|
windowManager?: string | null;
|
|
44
56
|
displayCount?: number | null;
|
|
45
|
-
alignmentMode?: "
|
|
57
|
+
alignmentMode?: "layer-shell";
|
|
58
|
+
rendererCardCount?: number;
|
|
59
|
+
rendererCardIds?: string[];
|
|
60
|
+
rendererVisibilityTransitionCount?: number;
|
|
61
|
+
controllerPid?: number;
|
|
62
|
+
panelPid?: number | null;
|
|
63
|
+
panelExpanded?: boolean;
|
|
64
|
+
clickThrough?: boolean;
|
|
65
|
+
panelMoveCount?: number;
|
|
66
|
+
panelActivationCount?: number;
|
|
67
|
+
panelRestartCount?: number;
|
|
68
|
+
lastMovedCardId?: string | null;
|
|
46
69
|
warnings?: string[];
|
|
47
70
|
error?: string | null;
|
|
48
71
|
}
|
|
@@ -63,6 +86,7 @@ export interface BrokerClientOptions {
|
|
|
63
86
|
|
|
64
87
|
export interface SessionStoreOptions {
|
|
65
88
|
staleAfterMs?: number;
|
|
89
|
+
maxSessions?: number;
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
export interface ActivityStripBrokerOptions {
|
|
@@ -71,10 +95,11 @@ export interface ActivityStripBrokerOptions {
|
|
|
71
95
|
store?: {
|
|
72
96
|
snapshot(): BrokerSnapshot;
|
|
73
97
|
upsert(session: Partial<SessionSnapshot> | Record<string, unknown>): boolean;
|
|
74
|
-
remove(sessionId: string): boolean;
|
|
98
|
+
remove(sessionId: string, publisherId?: string): boolean;
|
|
75
99
|
};
|
|
76
100
|
getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
|
|
77
|
-
focusSession?: (
|
|
101
|
+
focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
|
|
102
|
+
focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
export interface ToolCallDescription {
|
|
@@ -115,6 +140,11 @@ export interface TurnStartEventLike {
|
|
|
115
140
|
turnIndex?: number;
|
|
116
141
|
}
|
|
117
142
|
|
|
143
|
+
export interface TurnEndEventLike {
|
|
144
|
+
turnIndex?: number;
|
|
145
|
+
message?: unknown;
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
export interface AssistantMessageEventLike {
|
|
119
149
|
type?: string;
|
|
120
150
|
delta?: unknown;
|
|
@@ -136,15 +166,34 @@ export interface SessionTelemetryOptions {
|
|
|
136
166
|
pi?: TelemetryPiLike;
|
|
137
167
|
cwd?: string;
|
|
138
168
|
sessionName?: string;
|
|
169
|
+
env?: NodeJS.ProcessEnv;
|
|
170
|
+
stdinIsTTY?: boolean;
|
|
171
|
+
resolveTerminalIdentity?: (options: {
|
|
172
|
+
env?: NodeJS.ProcessEnv;
|
|
173
|
+
hasUI?: boolean;
|
|
174
|
+
stdinIsTTY?: boolean;
|
|
175
|
+
ttyPath?: string;
|
|
176
|
+
processId?: number;
|
|
177
|
+
ancestorExecutable?: string;
|
|
178
|
+
}) => {
|
|
179
|
+
terminalKind: "ghostty-surface" | "unbound";
|
|
180
|
+
terminalKey: string;
|
|
181
|
+
terminalFamily: string;
|
|
182
|
+
terminalSurfaceId: string;
|
|
183
|
+
};
|
|
184
|
+
transport?: {
|
|
185
|
+
publish: (session: SessionSnapshot) => Promise<unknown>;
|
|
186
|
+
remove: (session: { sessionId: string; publisherId: string }) => Promise<unknown>;
|
|
187
|
+
};
|
|
139
188
|
}
|
|
140
189
|
|
|
141
190
|
export interface ActivityStripCompatibilityReport {
|
|
142
191
|
ok: boolean;
|
|
192
|
+
backend: string;
|
|
143
193
|
displayServer: "wayland" | "x11" | "headless" | "unknown";
|
|
144
194
|
windowManager: string | null;
|
|
145
|
-
electronPath: string | null;
|
|
146
195
|
displayCount: number | null;
|
|
147
|
-
alignmentMode: "
|
|
196
|
+
alignmentMode: "layer-shell";
|
|
148
197
|
primaryDisplayOnly: boolean;
|
|
149
198
|
clickThroughDefault: boolean;
|
|
150
199
|
blockers: string[];
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
|
|
10
|
+
import {
|
|
11
|
+
appIdForGhosttyFamily,
|
|
12
|
+
canonicalGhosttyTerminalKey,
|
|
13
|
+
normalizeGhosttySurfaceId,
|
|
14
|
+
terminalTitleSegment,
|
|
15
|
+
} from "./terminal-identity.mjs";
|
|
9
16
|
|
|
10
17
|
const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
|
|
11
18
|
const PI_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -92,11 +99,15 @@ export function shortSessionId(value, hexLength = SESSION_TITLE_TOKEN_HEX_LENGTH
|
|
|
92
99
|
.toLowerCase();
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
/** @param {Array<Record<string, unknown>>} sessions @param {string}
|
|
96
|
-
export function resolveSnapshotSession(sessions,
|
|
97
|
-
const requestedId = String(
|
|
98
|
-
const
|
|
99
|
-
|
|
102
|
+
/** @param {Array<Record<string, unknown>>} sessions @param {string} targetId */
|
|
103
|
+
export function resolveSnapshotSession(sessions, targetId) {
|
|
104
|
+
const requestedId = String(targetId ?? "");
|
|
105
|
+
const cardMatches = sessions.filter(
|
|
106
|
+
(session) => session?.cardId === requestedId || sessionCardId(session) === requestedId,
|
|
107
|
+
);
|
|
108
|
+
if (cardMatches.length === 1) return cardMatches[0];
|
|
109
|
+
const logicalMatches = sessions.filter((session) => session?.sessionId === requestedId);
|
|
110
|
+
return logicalMatches.length === 1 ? logicalMatches[0] : null;
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/**
|
|
@@ -105,8 +116,9 @@ export function resolveSnapshotSession(sessions, sessionId) {
|
|
|
105
116
|
* must also identify a known Ghostty build, and ambiguity always returns no match.
|
|
106
117
|
* @param {Array<Record<string, unknown>>} windows
|
|
107
118
|
* @param {string} sessionId
|
|
119
|
+
* @param {Record<string, unknown>} [session]
|
|
108
120
|
*/
|
|
109
|
-
export function resolveExactGhosttyWindow(windows, sessionId) {
|
|
121
|
+
export function resolveExactGhosttyWindow(windows, sessionId, session = {}) {
|
|
110
122
|
const fullId = String(sessionId ?? "").trim();
|
|
111
123
|
if (!PI_SESSION_ID.test(fullId)) return null;
|
|
112
124
|
|
|
@@ -123,6 +135,22 @@ export function resolveExactGhosttyWindow(windows, sessionId) {
|
|
|
123
135
|
};
|
|
124
136
|
|
|
125
137
|
const currentToken = shortSessionId(fullId);
|
|
138
|
+
if (session.terminalKind === "ghostty-surface" && !canonicalGhosttyTerminalKey(session)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const surfaceId = normalizeGhosttySurfaceId(session.terminalSurfaceId);
|
|
142
|
+
const titleSegment = terminalTitleSegment(session);
|
|
143
|
+
const expectedAppId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
|
|
144
|
+
if (surfaceId && titleSegment && expectedAppId) {
|
|
145
|
+
const surfaceSuffix = ` · ${titleSegment} · ${currentToken}`;
|
|
146
|
+
const surfaceMatches = windows.filter(
|
|
147
|
+
(window) =>
|
|
148
|
+
Number.isInteger(window?.id) &&
|
|
149
|
+
String(window?.app_id ?? "") === expectedAppId &&
|
|
150
|
+
String(window?.title ?? "").endsWith(surfaceSuffix),
|
|
151
|
+
);
|
|
152
|
+
return surfaceMatches.length === 1 ? surfaceMatches[0] : null;
|
|
153
|
+
}
|
|
126
154
|
const currentMatches = matchesForToken(currentToken);
|
|
127
155
|
if (currentMatches.length > 0) return currentMatches.length === 1 ? currentMatches[0] : null;
|
|
128
156
|
|
|
@@ -153,16 +181,15 @@ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {})
|
|
|
153
181
|
const matches = sessions.filter((session) => {
|
|
154
182
|
const sessionId = resolvePiSessionIdentity(session, options);
|
|
155
183
|
if (!sessionId) return false;
|
|
156
|
-
return resolveExactGhosttyWindow(windows, sessionId)?.id === focusedWindow.id;
|
|
184
|
+
return resolveExactGhosttyWindow(windows, sessionId, session)?.id === focusedWindow.id;
|
|
157
185
|
});
|
|
158
186
|
return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
|
|
159
187
|
}
|
|
160
188
|
|
|
161
189
|
/**
|
|
162
190
|
* Project the global broker snapshot onto one exact Niri workspace. Membership
|
|
163
|
-
* requires an exact
|
|
164
|
-
*
|
|
165
|
-
* not affect membership.
|
|
191
|
+
* requires an exact Ghostty window. Multiple publisher streams bound to the same terminal are
|
|
192
|
+
* aggregated into one stable card; ambiguity between distinct windows still fails closed.
|
|
166
193
|
* @param {Array<Record<string, unknown>>} windows
|
|
167
194
|
* @param {Record<string, unknown>} workspace
|
|
168
195
|
* @param {Array<Record<string, unknown>>} sessions
|
|
@@ -170,26 +197,57 @@ export function resolveFocusedSnapshotSessionId(windows, sessions, options = {})
|
|
|
170
197
|
*/
|
|
171
198
|
export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
|
|
172
199
|
if (!Number.isInteger(workspace?.id)) return null;
|
|
173
|
-
/** @type {Array<{session: Record<string, unknown>; window: Record<string, unknown>}>} */
|
|
200
|
+
/** @type {Array<{cardId: string; session: Record<string, unknown>; window: Record<string, unknown>}>} */
|
|
174
201
|
const candidates = [];
|
|
175
202
|
for (const session of sessions) {
|
|
176
203
|
const sessionIdentity = resolvePiSessionIdentity(session, options);
|
|
177
204
|
if (!sessionIdentity) continue;
|
|
178
|
-
const window = resolveExactGhosttyWindow(windows, sessionIdentity);
|
|
205
|
+
const window = resolveExactGhosttyWindow(windows, sessionIdentity, session);
|
|
179
206
|
if (!window || window.workspace_id !== workspace.id) continue;
|
|
180
|
-
|
|
207
|
+
const cardId = sessionCardId(session);
|
|
208
|
+
if (!cardId) continue;
|
|
209
|
+
candidates.push({ cardId, session: { ...session, cardId }, window });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cardIdsByWindow = new Map();
|
|
213
|
+
for (const candidate of candidates) {
|
|
214
|
+
const ids = cardIdsByWindow.get(candidate.window.id) ?? new Set();
|
|
215
|
+
ids.add(candidate.cardId);
|
|
216
|
+
cardIdsByWindow.set(candidate.window.id, ids);
|
|
181
217
|
}
|
|
182
|
-
const
|
|
218
|
+
const allowedCardByWindow = new Map();
|
|
219
|
+
for (const [windowId, ids] of cardIdsByWindow) {
|
|
220
|
+
const terminalIds = [...ids].filter((cardId) => cardId.startsWith("terminal:"));
|
|
221
|
+
if (terminalIds.length === 1) allowedCardByWindow.set(windowId, terminalIds[0]);
|
|
222
|
+
else if (ids.size === 1) allowedCardByWindow.set(windowId, [...ids][0]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @type {Map<string, Array<{session: Record<string, unknown>; window: Record<string, unknown>}>>} */
|
|
226
|
+
const groups = new Map();
|
|
183
227
|
for (const candidate of candidates) {
|
|
184
|
-
|
|
228
|
+
if (allowedCardByWindow.get(candidate.window.id) !== candidate.cardId) continue;
|
|
229
|
+
const group = groups.get(candidate.cardId) ?? [];
|
|
230
|
+
group.push(candidate);
|
|
231
|
+
groups.set(candidate.cardId, group);
|
|
185
232
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
233
|
+
|
|
234
|
+
const projectedSessions = [];
|
|
235
|
+
for (const [cardId, group] of groups) {
|
|
236
|
+
const windowIds = new Set(group.map((candidate) => candidate.window.id));
|
|
237
|
+
if (windowIds.size !== 1) continue;
|
|
238
|
+
const [card] = projectSessionCards(group.map((candidate) => candidate.session));
|
|
239
|
+
if (!card) continue;
|
|
240
|
+
const window = group[0]?.window;
|
|
241
|
+
projectedSessions.push({ ...card, cardId, windowId: window?.id });
|
|
242
|
+
}
|
|
243
|
+
const focusedCard = projectedSessions.find((session) =>
|
|
244
|
+
windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
|
|
245
|
+
);
|
|
189
246
|
return {
|
|
190
247
|
workspace,
|
|
191
248
|
sessions: projectedSessions,
|
|
192
|
-
focusedSessionId:
|
|
249
|
+
focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
|
|
250
|
+
focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
|
|
193
251
|
};
|
|
194
252
|
}
|
|
195
253
|
|
|
@@ -216,65 +274,6 @@ export function resolveFocusedNiriWorkspace(workspaces) {
|
|
|
216
274
|
return matches.length === 1 ? matches[0] : null;
|
|
217
275
|
}
|
|
218
276
|
|
|
219
|
-
/**
|
|
220
|
-
* @param {Array<Record<string, unknown>>} windows
|
|
221
|
-
* @param {number | null} [workspaceId]
|
|
222
|
-
*/
|
|
223
|
-
export function resolveActivityStripWindow(windows, workspaceId = null) {
|
|
224
|
-
const matches = windows.filter(
|
|
225
|
-
(window) =>
|
|
226
|
-
Number.isInteger(window?.id) &&
|
|
227
|
-
window?.title === "Pi Activity Strip" &&
|
|
228
|
-
(workspaceId == null || window?.workspace_id === workspaceId),
|
|
229
|
-
);
|
|
230
|
-
return matches.length === 1 ? matches[0] : null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Focus the unique strip already resident on the focused workspace. This command is
|
|
235
|
-
* designed for a compositor key binding and never moves a strip across workspaces.
|
|
236
|
-
* @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
|
|
237
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
238
|
-
* @param {Array<unknown>} [sessions]
|
|
239
|
-
*/
|
|
240
|
-
export async function focusNiriStrip(execFileAsync, env = process.env, sessions = []) {
|
|
241
|
-
if (!env.NIRI_SOCKET)
|
|
242
|
-
return { ok: false, error: "Niri is not available; strip focus did nothing." };
|
|
243
|
-
try {
|
|
244
|
-
const [{ stdout: windowOutput }, { stdout: workspaceOutput }] = await Promise.all([
|
|
245
|
-
execFileAsync("niri", ["msg", "-j", "windows"], { env }),
|
|
246
|
-
execFileAsync("niri", ["msg", "-j", "workspaces"], { env }),
|
|
247
|
-
]);
|
|
248
|
-
const windows = JSON.parse(String(windowOutput ?? "[]"));
|
|
249
|
-
const workspaces = JSON.parse(String(workspaceOutput ?? "[]"));
|
|
250
|
-
if (!Array.isArray(windows) || !Array.isArray(workspaces))
|
|
251
|
-
throw new Error("unexpected payload");
|
|
252
|
-
const focusedWorkspace = resolveFocusedNiriWorkspace(workspaces);
|
|
253
|
-
const strip = focusedWorkspace
|
|
254
|
-
? resolveActivityStripWindow(windows, Number(focusedWorkspace.id))
|
|
255
|
-
: null;
|
|
256
|
-
const sessionRecords = Array.isArray(sessions)
|
|
257
|
-
? /** @type {Array<Record<string, unknown>>} */ (sessions)
|
|
258
|
-
: [];
|
|
259
|
-
const view = resolveFocusedWorkspaceView(windows, workspaces, sessionRecords, { env });
|
|
260
|
-
if (!strip || !view?.sessions.length) {
|
|
261
|
-
return {
|
|
262
|
-
ok: false,
|
|
263
|
-
error: "No visible resident strip with exact tracked sessions exists here; nothing moved.",
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(strip.id)], {
|
|
267
|
-
env,
|
|
268
|
-
});
|
|
269
|
-
return { ok: true, windowId: strip.id };
|
|
270
|
-
} catch {
|
|
271
|
-
return {
|
|
272
|
-
ok: false,
|
|
273
|
-
error: "Could not focus the strip through Niri; nothing else was focused.",
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
277
|
/**
|
|
279
278
|
* @param {string | Record<string, unknown>} session
|
|
280
279
|
* @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
|
|
@@ -304,7 +303,8 @@ export async function focusNiriSession(session, execFileAsync, env = process.env
|
|
|
304
303
|
if (!Array.isArray(windows)) {
|
|
305
304
|
return { ok: false, error: "Unexpected Niri window data; focus did nothing." };
|
|
306
305
|
}
|
|
307
|
-
const
|
|
306
|
+
const record = session && typeof session === "object" ? session : {};
|
|
307
|
+
const target = resolveExactGhosttyWindow(windows, sessionId, record);
|
|
308
308
|
if (!target) {
|
|
309
309
|
return {
|
|
310
310
|
ok: false,
|
package/src/common/protocol.mjs
CHANGED
|
@@ -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,36 +39,55 @@ 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 =
|
|
28
|
-
const
|
|
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);
|
|
29
48
|
const agentStartedAt =
|
|
30
|
-
session.agentStartedAt == null ? null :
|
|
49
|
+
session.agentStartedAt == null || !normalizedAgentStartedAt ? null : normalizedAgentStartedAt;
|
|
31
50
|
const rawState = typeof session.state === "string" ? session.state : "idle";
|
|
32
51
|
/** @type {SessionState} */
|
|
33
52
|
const state = SESSION_STATES.has(/** @type {SessionState} */ (rawState))
|
|
34
53
|
? /** @type {SessionState} */ (rawState)
|
|
35
54
|
: "idle";
|
|
36
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;
|
|
37
65
|
return {
|
|
38
|
-
sessionId:
|
|
66
|
+
sessionId: boundedString(session.sessionId, 256),
|
|
67
|
+
publisherId: boundedString(session.publisherId, 256),
|
|
68
|
+
publisherSequence,
|
|
39
69
|
processId: Number(session.processId ?? 0) || 0,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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),
|
|
48
82
|
state,
|
|
49
83
|
turnIndex: Number(session.turnIndex ?? 0) || 0,
|
|
50
84
|
updatedAt,
|
|
85
|
+
lastEventAt,
|
|
51
86
|
startedAt,
|
|
52
87
|
agentStartedAt,
|
|
53
88
|
agentActive: Boolean(session.agentActive),
|
|
54
|
-
lastPromptPreview:
|
|
55
|
-
errorMessage:
|
|
89
|
+
lastPromptPreview: boundedString(session.lastPromptPreview, 2048),
|
|
90
|
+
errorMessage: boundedString(session.errorMessage, 2048),
|
|
56
91
|
};
|
|
57
92
|
}
|
|
58
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
|
+
}
|