@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.
- package/README.md +136 -157
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +50 -60
- 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 +602 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +297 -0
- package/native/panel/src/runtime.rs +151 -0
- package/native/panel/src/style.css +250 -0
- package/package.json +16 -4
- package/src/broker/server.mjs +43 -7
- package/src/broker/session-store.mjs +41 -13
- package/src/client/broker-client.mjs +10 -3
- package/src/client/session-telemetry.mjs +54 -14
- package/src/common/activity-order.mjs +15 -9
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +5 -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 +28 -13
- package/src/common/constants.mjs +10 -28
- package/src/common/contracts.d.ts +68 -4
- package/src/common/contracts.ts +105 -4
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +209 -140
- package/src/common/protocol.mjs +48 -16
- package/src/common/session-cards.mjs +105 -0
- package/src/common/status-report.mjs +53 -1
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/telemetry.mjs +8 -1
- package/src/common/terminal-identity.mjs +212 -0
- 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 +499 -0
- package/src/native/panel-projection.mjs +161 -0
- 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/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
- 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 -500
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryinget/pi-activity-strip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "pi extension package for activity-strip workflows in monorepo runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=22.19.0"
|
|
28
28
|
},
|
|
29
|
+
"os": [
|
|
30
|
+
"linux"
|
|
31
|
+
],
|
|
32
|
+
"cpu": [
|
|
33
|
+
"x64"
|
|
34
|
+
],
|
|
29
35
|
"scripts": {
|
|
30
36
|
"fix": "bash ./scripts/quality-gate.sh fix",
|
|
31
37
|
"lint": "bash ./scripts/quality-gate.sh lint",
|
|
@@ -40,6 +46,8 @@
|
|
|
40
46
|
"docs:list:json": "node ~/ai-society/core/agent-scripts/scripts/docs-list.mjs --json",
|
|
41
47
|
"release:check": "bash ./scripts/release-check.sh",
|
|
42
48
|
"release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh",
|
|
49
|
+
"native:check": "cd native/panel && RUSTUP_TOOLCHAIN=stable cargo fmt --check && RUSTUP_TOOLCHAIN=stable cargo test",
|
|
50
|
+
"native:build": "bash ./scripts/build-native-panel.sh",
|
|
43
51
|
"strip:open": "node ./bin/pi-activity-strip.mjs open",
|
|
44
52
|
"strip:status": "node ./bin/pi-activity-strip.mjs status",
|
|
45
53
|
"strip:doctor": "node ./bin/pi-activity-strip.mjs doctor",
|
|
@@ -56,6 +64,10 @@
|
|
|
56
64
|
"extensions/activity-strip.js",
|
|
57
65
|
"extensions/activity-strip.ts",
|
|
58
66
|
"src",
|
|
67
|
+
"native/bin",
|
|
68
|
+
"native/panel/Cargo.toml",
|
|
69
|
+
"native/panel/Cargo.lock",
|
|
70
|
+
"native/panel/src",
|
|
59
71
|
"prompts",
|
|
60
72
|
"examples",
|
|
61
73
|
"policy/security-policy.json"
|
|
@@ -76,9 +88,9 @@
|
|
|
76
88
|
},
|
|
77
89
|
"devDependencies": {
|
|
78
90
|
"@biomejs/biome": "2.4.4",
|
|
79
|
-
"@earendil-works/pi-agent-core": "0.84.
|
|
80
|
-
"@earendil-works/pi-ai": "0.84.
|
|
81
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
91
|
+
"@earendil-works/pi-agent-core": "0.84.4",
|
|
92
|
+
"@earendil-works/pi-ai": "0.84.4",
|
|
93
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
82
94
|
"@types/node": "22.14.0",
|
|
83
95
|
"@typescript/native-preview": "7.0.0-dev.20260417.1",
|
|
84
96
|
"typebox": "1.3.7",
|
package/src/broker/server.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import net from "node:net";
|
|
|
10
10
|
/** @typedef {import("../common/contracts.ts").ActivityStripBrokerOptions} ActivityStripBrokerOptions */
|
|
11
11
|
import {
|
|
12
12
|
ACTIVITY_STRIP_BROADCAST_TICK_MS,
|
|
13
|
+
ACTIVITY_STRIP_CLIENT_IDLE_TIMEOUT_MS,
|
|
14
|
+
ACTIVITY_STRIP_MAX_MESSAGE_BYTES,
|
|
13
15
|
ACTIVITY_STRIP_SOCKET_DIR,
|
|
14
16
|
ACTIVITY_STRIP_SOCKET_PATH,
|
|
15
17
|
} from "../common/constants.mjs";
|
|
@@ -49,6 +51,8 @@ export class ActivityStripBroker extends EventEmitter {
|
|
|
49
51
|
this.getRuntimeStatus = options.getRuntimeStatus ?? (() => undefined);
|
|
50
52
|
this.focusSession =
|
|
51
53
|
options.focusSession ?? (async () => ({ ok: false, error: "Focus unavailable" }));
|
|
54
|
+
this.focusStrip =
|
|
55
|
+
options.focusStrip ?? (async () => ({ ok: false, error: "Strip focus unavailable" }));
|
|
52
56
|
this.server = net.createServer((socket) => this.handleConnection(socket));
|
|
53
57
|
this.tick = null;
|
|
54
58
|
}
|
|
@@ -98,12 +102,18 @@ export class ActivityStripBroker extends EventEmitter {
|
|
|
98
102
|
handleConnection(socket) {
|
|
99
103
|
let buffer = "";
|
|
100
104
|
socket.setEncoding("utf8");
|
|
105
|
+
socket.setTimeout?.(ACTIVITY_STRIP_CLIENT_IDLE_TIMEOUT_MS, () => socket.destroy());
|
|
101
106
|
socket.on("error", (error) => {
|
|
102
107
|
if (!isExpectedClientDisconnect(error)) this.emit("client-error", error);
|
|
103
108
|
});
|
|
104
109
|
|
|
105
110
|
socket.on("data", (chunk) => {
|
|
106
111
|
buffer += chunk;
|
|
112
|
+
if (Buffer.byteLength(buffer, "utf8") > ACTIVITY_STRIP_MAX_MESSAGE_BYTES) {
|
|
113
|
+
this.reply(socket, { ok: false, error: "Activity strip broker message is too large." });
|
|
114
|
+
socket.destroy();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
107
117
|
let newlineIndex = buffer.indexOf("\n");
|
|
108
118
|
while (newlineIndex >= 0) {
|
|
109
119
|
const line = buffer.slice(0, newlineIndex);
|
|
@@ -139,26 +149,52 @@ export class ActivityStripBroker extends EventEmitter {
|
|
|
139
149
|
});
|
|
140
150
|
return;
|
|
141
151
|
case "focus":
|
|
142
|
-
Promise.resolve(this.focusSession(String(message.sessionId ?? "")))
|
|
152
|
+
Promise.resolve(this.focusSession(String(message.cardId ?? message.sessionId ?? "")))
|
|
143
153
|
.then((result) => this.reply(socket, { type: "focus", ...result }))
|
|
144
154
|
.catch(() =>
|
|
145
155
|
this.reply(socket, { ok: false, type: "focus", error: "Focus failed closed." }),
|
|
146
156
|
);
|
|
147
157
|
return;
|
|
158
|
+
case "focus-strip":
|
|
159
|
+
Promise.resolve(this.focusStrip())
|
|
160
|
+
.then((result) => this.reply(socket, { type: "focus-strip", ...result }))
|
|
161
|
+
.catch(() =>
|
|
162
|
+
this.reply(socket, {
|
|
163
|
+
ok: false,
|
|
164
|
+
type: "focus-strip",
|
|
165
|
+
error: "Strip focus failed closed.",
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
148
169
|
case "shutdown":
|
|
149
170
|
this.reply(socket, { ok: true, type: "shutdown" });
|
|
150
171
|
setTimeout(() => {
|
|
151
172
|
this.emit("shutdown-requested");
|
|
152
173
|
}, 20);
|
|
153
174
|
return;
|
|
154
|
-
case "remove":
|
|
155
|
-
this.store.remove(
|
|
156
|
-
|
|
175
|
+
case "remove": {
|
|
176
|
+
const accepted = this.store.remove(
|
|
177
|
+
String(message.sessionId ?? ""),
|
|
178
|
+
String(message.publisherId ?? ""),
|
|
179
|
+
);
|
|
180
|
+
if (accepted) this.emitSnapshot();
|
|
181
|
+
this.reply(socket, {
|
|
182
|
+
ok: accepted,
|
|
183
|
+
type: "remove",
|
|
184
|
+
...(accepted ? {} : { error: "Activity strip broker rejected remove." }),
|
|
185
|
+
});
|
|
157
186
|
return;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
this.
|
|
187
|
+
}
|
|
188
|
+
case "upsert": {
|
|
189
|
+
const accepted = this.store.upsert(message.session);
|
|
190
|
+
if (accepted) this.emitSnapshot();
|
|
191
|
+
this.reply(socket, {
|
|
192
|
+
ok: accepted,
|
|
193
|
+
type: "upsert",
|
|
194
|
+
...(accepted ? {} : { error: "Activity strip broker rejected upsert." }),
|
|
195
|
+
});
|
|
161
196
|
return;
|
|
197
|
+
}
|
|
162
198
|
default:
|
|
163
199
|
this.reply(socket, { ok: false, error: `Unsupported message type: ${message.type}` });
|
|
164
200
|
}
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// - "changing session retention, upsert semantics, or broker snapshot ordering"
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ACTIVITY_STRIP_MAX_SESSIONS,
|
|
9
|
+
ACTIVITY_STRIP_STALE_AFTER_MS,
|
|
10
|
+
} from "../common/constants.mjs";
|
|
8
11
|
import { normalizeSessionSnapshot, sortSessions } from "../common/protocol.mjs";
|
|
9
12
|
|
|
10
13
|
/** @typedef {import("../common/contracts.ts").SessionSnapshot} SessionSnapshot */
|
|
@@ -25,36 +28,61 @@ export class SessionStore {
|
|
|
25
28
|
/** @param {SessionStoreOptions} [options] */
|
|
26
29
|
constructor(options = {}) {
|
|
27
30
|
this.staleAfterMs = options.staleAfterMs ?? ACTIVITY_STRIP_STALE_AFTER_MS;
|
|
28
|
-
|
|
31
|
+
this.maxSessions = options.maxSessions ?? ACTIVITY_STRIP_MAX_SESSIONS;
|
|
32
|
+
/** @type {Map<string, {session: SessionSnapshot; receivedAt: number}>} */
|
|
29
33
|
this.sessions = new Map();
|
|
34
|
+
/** @type {Map<string, number>} */
|
|
35
|
+
this.tombstones = new Map();
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
/** @param {Partial<SessionSnapshot> | Record<string, unknown>} session */
|
|
33
|
-
upsert(session) {
|
|
38
|
+
/** @param {Partial<SessionSnapshot> | Record<string, unknown>} session @param {number} [receivedAt] */
|
|
39
|
+
upsert(session, receivedAt = Date.now()) {
|
|
40
|
+
if (!session || typeof session !== "object") return false;
|
|
41
|
+
this.purge(receivedAt);
|
|
34
42
|
const normalized = normalizeSessionSnapshot(session);
|
|
35
43
|
if (!normalized.sessionId) return false;
|
|
36
|
-
|
|
44
|
+
const key = sessionKey(normalized);
|
|
45
|
+
if (this.tombstones.has(key)) return false;
|
|
46
|
+
const current = this.sessions.get(key);
|
|
47
|
+
if (
|
|
48
|
+
current &&
|
|
49
|
+
current.session.publisherSequence > 0 &&
|
|
50
|
+
(normalized.publisherSequence === 0 ||
|
|
51
|
+
normalized.publisherSequence <= current.session.publisherSequence)
|
|
52
|
+
) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!current && this.sessions.size >= this.maxSessions) return false;
|
|
56
|
+
this.sessions.set(key, { session: normalized, receivedAt });
|
|
37
57
|
return true;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
/** @param {string} sessionId @param {string} [publisherId] */
|
|
41
|
-
remove(sessionId, publisherId) {
|
|
60
|
+
/** @param {string} sessionId @param {string} [publisherId] @param {number} [removedAt] */
|
|
61
|
+
remove(sessionId, publisherId, removedAt = Date.now()) {
|
|
42
62
|
const id = String(sessionId ?? "");
|
|
43
63
|
if (!id) return false;
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
if (!publisherId) return this.sessions.delete(id);
|
|
65
|
+
const key = `${id}|${String(publisherId)}`;
|
|
66
|
+
this.purge(removedAt);
|
|
67
|
+
if (!this.sessions.has(key)) return false;
|
|
68
|
+
if (!this.tombstones.has(key) && this.tombstones.size >= this.maxSessions) return false;
|
|
69
|
+
this.sessions.delete(key);
|
|
70
|
+
this.tombstones.set(key, removedAt);
|
|
71
|
+
return true;
|
|
47
72
|
}
|
|
48
73
|
|
|
49
74
|
/** @param {number} [now] */
|
|
50
75
|
purge(now = Date.now()) {
|
|
51
76
|
let changed = false;
|
|
52
|
-
for (const [sessionId,
|
|
53
|
-
if (now -
|
|
77
|
+
for (const [sessionId, entry] of this.sessions.entries()) {
|
|
78
|
+
if (now - entry.receivedAt > this.staleAfterMs) {
|
|
54
79
|
this.sessions.delete(sessionId);
|
|
55
80
|
changed = true;
|
|
56
81
|
}
|
|
57
82
|
}
|
|
83
|
+
for (const [sessionId, removedAt] of this.tombstones.entries()) {
|
|
84
|
+
if (now - removedAt > this.staleAfterMs) this.tombstones.delete(sessionId);
|
|
85
|
+
}
|
|
58
86
|
return changed;
|
|
59
87
|
}
|
|
60
88
|
|
|
@@ -63,7 +91,7 @@ export class SessionStore {
|
|
|
63
91
|
this.purge(now);
|
|
64
92
|
return {
|
|
65
93
|
generatedAt: now,
|
|
66
|
-
sessions: sortSessions([...this.sessions.values()]),
|
|
94
|
+
sessions: sortSessions([...this.sessions.values()].map((entry) => entry.session)),
|
|
67
95
|
};
|
|
68
96
|
}
|
|
69
97
|
}
|
|
@@ -112,7 +112,12 @@ export async function requestBrokerShutdown(options = {}) {
|
|
|
112
112
|
|
|
113
113
|
/** @param {SessionSnapshot} session @param {BrokerClientOptions} [options] */
|
|
114
114
|
export async function publishSessionSnapshot(session, options = {}) {
|
|
115
|
-
await sendBrokerMessage(makeMessage("upsert", { session }),
|
|
115
|
+
const result = await sendBrokerMessage(makeMessage("upsert", { session }), {
|
|
116
|
+
...options,
|
|
117
|
+
expectReply: true,
|
|
118
|
+
});
|
|
119
|
+
if (result?.ok !== true)
|
|
120
|
+
throw new Error(result?.error || "Activity strip broker rejected upsert.");
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
/** @param {{ sessionId: string; publisherId: string }} session @param {BrokerClientOptions} [options] */
|
|
@@ -125,11 +130,13 @@ export async function removeSession(session, options = {}) {
|
|
|
125
130
|
} else {
|
|
126
131
|
fallbackId = String(session ?? "");
|
|
127
132
|
}
|
|
128
|
-
await sendBrokerMessage(
|
|
133
|
+
const result = await sendBrokerMessage(
|
|
129
134
|
makeMessage("remove", {
|
|
130
135
|
sessionId: String(record.sessionId ?? fallbackId),
|
|
131
136
|
publisherId: String(record.publisherId ?? ""),
|
|
132
137
|
}),
|
|
133
|
-
options,
|
|
138
|
+
{ ...options, expectReply: true },
|
|
134
139
|
);
|
|
140
|
+
if (result?.ok !== true)
|
|
141
|
+
throw new Error(result?.error || "Activity strip broker rejected remove.");
|
|
135
142
|
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
extractToolResultText,
|
|
31
31
|
summarizeToolResult,
|
|
32
32
|
} from "../common/telemetry.mjs";
|
|
33
|
+
import { resolveTerminalIdentity as resolveTerminalIdentityDefault } from "../common/terminal-identity.mjs";
|
|
33
34
|
import { publishSessionSnapshot, removeSession } from "./broker-client.mjs";
|
|
34
35
|
|
|
35
36
|
function now() {
|
|
@@ -47,6 +48,9 @@ export function createSessionTelemetry({
|
|
|
47
48
|
pi,
|
|
48
49
|
cwd = process.cwd(),
|
|
49
50
|
sessionName = "",
|
|
51
|
+
env = process.env,
|
|
52
|
+
stdinIsTTY = Boolean(process.stdin.isTTY),
|
|
53
|
+
resolveTerminalIdentity = resolveTerminalIdentityDefault,
|
|
50
54
|
transport,
|
|
51
55
|
} = {}) {
|
|
52
56
|
const publish = transport?.publish ?? publishSessionSnapshot;
|
|
@@ -62,6 +66,9 @@ export function createSessionTelemetry({
|
|
|
62
66
|
let lastStopReason = "";
|
|
63
67
|
let confirmedSignature = "";
|
|
64
68
|
let retryIndex = 0;
|
|
69
|
+
/** @type {Promise<void> | null} */
|
|
70
|
+
let flushPromise = null;
|
|
71
|
+
let flushAgain = false;
|
|
65
72
|
|
|
66
73
|
/** Signature of the display-relevant fields, used to detect lost transitions. */
|
|
67
74
|
function publishSignature() {
|
|
@@ -75,15 +82,20 @@ export function createSessionTelemetry({
|
|
|
75
82
|
snapshot.toolName,
|
|
76
83
|
snapshot.toolTarget,
|
|
77
84
|
snapshot.errorMessage,
|
|
85
|
+
snapshot.terminalKey,
|
|
78
86
|
].join("\u001f");
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
function clearScheduledFlush() {
|
|
90
|
+
if (!flushTimer) return;
|
|
91
|
+
clearTimeout(flushTimer);
|
|
82
92
|
flushTimer = null;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function publishLatest() {
|
|
96
|
+
// Heartbeats refresh transport liveness only; lastEventAt remains the last real event.
|
|
86
97
|
snapshot.updatedAt = now();
|
|
98
|
+
snapshot.publisherSequence += 1;
|
|
87
99
|
snapshot.repoLabel = formatRepoLabel(
|
|
88
100
|
snapshot.cwd,
|
|
89
101
|
pi?.getSessionName?.() ?? snapshot.sessionName,
|
|
@@ -91,29 +103,48 @@ export function createSessionTelemetry({
|
|
|
91
103
|
snapshot.sessionName = compactWhitespace(pi?.getSessionName?.() ?? snapshot.sessionName);
|
|
92
104
|
const signature = publishSignature();
|
|
93
105
|
try {
|
|
94
|
-
await publish(snapshot);
|
|
106
|
+
await publish({ ...snapshot });
|
|
95
107
|
confirmedSignature = signature;
|
|
96
108
|
retryIndex = 0;
|
|
97
109
|
} catch {
|
|
98
|
-
// Broker is optional at runtime; silent failure keeps pi stable. Retry a
|
|
99
|
-
// bounded number of times when a state transition may not have landed so
|
|
100
|
-
// a transient connect failure cannot freeze the displayed state.
|
|
101
110
|
if (
|
|
111
|
+
!disposed &&
|
|
102
112
|
signature !== confirmedSignature &&
|
|
103
113
|
retryIndex < ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS.length
|
|
104
114
|
) {
|
|
105
|
-
|
|
115
|
+
const delayMs = ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS[retryIndex];
|
|
106
116
|
retryIndex += 1;
|
|
117
|
+
scheduleFlush(delayMs);
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
121
|
|
|
111
|
-
function
|
|
122
|
+
async function flush() {
|
|
123
|
+
clearScheduledFlush();
|
|
112
124
|
if (disposed) return;
|
|
113
|
-
if (
|
|
125
|
+
if (flushPromise) {
|
|
126
|
+
flushAgain = true;
|
|
127
|
+
return flushPromise;
|
|
128
|
+
}
|
|
129
|
+
flushPromise = (async () => {
|
|
130
|
+
do {
|
|
131
|
+
flushAgain = false;
|
|
132
|
+
if (disposed) break;
|
|
133
|
+
await publishLatest();
|
|
134
|
+
} while (flushAgain && !disposed);
|
|
135
|
+
})().finally(() => {
|
|
136
|
+
flushPromise = null;
|
|
137
|
+
});
|
|
138
|
+
return flushPromise;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scheduleFlush(delayMs = ACTIVITY_STRIP_SEND_THROTTLE_MS) {
|
|
142
|
+
if (disposed || flushTimer) return;
|
|
114
143
|
flushTimer = setTimeout(() => {
|
|
144
|
+
flushTimer = null;
|
|
115
145
|
flush().catch(() => {});
|
|
116
146
|
}, delayMs);
|
|
147
|
+
flushTimer.unref?.();
|
|
117
148
|
}
|
|
118
149
|
|
|
119
150
|
function startHeartbeat() {
|
|
@@ -149,8 +180,15 @@ export function createSessionTelemetry({
|
|
|
149
180
|
async onSessionStart(ctx) {
|
|
150
181
|
const exactSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "").trim();
|
|
151
182
|
const nextCwd = ctx?.cwd ?? snapshot.cwd;
|
|
183
|
+
const terminalIdentity = resolveTerminalIdentity({
|
|
184
|
+
env,
|
|
185
|
+
hasUI: Boolean(ctx?.hasUI),
|
|
186
|
+
stdinIsTTY,
|
|
187
|
+
processId: process.pid,
|
|
188
|
+
});
|
|
152
189
|
update({
|
|
153
190
|
...(exactSessionId ? { sessionId: exactSessionId } : {}),
|
|
191
|
+
...terminalIdentity,
|
|
154
192
|
cwd: nextCwd,
|
|
155
193
|
sessionName: pi?.getSessionName?.() ?? snapshot.sessionName,
|
|
156
194
|
detail: previewPath(nextCwd, 72) || "Ready",
|
|
@@ -276,9 +314,11 @@ export function createSessionTelemetry({
|
|
|
276
314
|
async shutdown() {
|
|
277
315
|
disposed = true;
|
|
278
316
|
stopHeartbeat();
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
317
|
+
clearScheduledFlush();
|
|
318
|
+
try {
|
|
319
|
+
await flushPromise;
|
|
320
|
+
} catch {
|
|
321
|
+
// Broker absence remains non-fatal; publisher removal is still attempted below.
|
|
282
322
|
}
|
|
283
323
|
try {
|
|
284
324
|
await removePublisher({
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
const ACTIVE_STATES = new Set(["thinking", "tool", "waiting"]);
|
|
8
8
|
|
|
9
|
+
/** @param {{cardId?: string; sessionId?: string}} session */
|
|
10
|
+
export function activityCardKey(session) {
|
|
11
|
+
return String(session?.cardId ?? session?.sessionId ?? "");
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
/** @param {{ state?: string; agentActive?: boolean }} session */
|
|
10
15
|
export function isActiveSession(session) {
|
|
11
16
|
return Boolean(session?.agentActive || ACTIVE_STATES.has(String(session?.state ?? "")));
|
|
@@ -24,18 +29,19 @@ export function isMonitoringSession(session) {
|
|
|
24
29
|
* Reconcile at the calm clock: green settled/monitoring cards first, then active cards,
|
|
25
30
|
* then other inactive cards. Preserve previous/manual order inside each group and append
|
|
26
31
|
* new cards deterministically.
|
|
27
|
-
* @param {Array<{ sessionId: string; state?: string; agentActive?: boolean; toolName?: string; toolTarget?: string; updatedAt?: number }>} sessions
|
|
32
|
+
* @param {Array<{ sessionId: string; cardId?: string; state?: string; agentActive?: boolean; toolName?: string; toolTarget?: string; updatedAt?: number }>} sessions
|
|
28
33
|
* @param {string[]} previousOrder
|
|
29
34
|
* @param {{ regroup?: boolean }} [options]
|
|
30
35
|
*/
|
|
31
36
|
export function reconcileActivityOrder(sessions, previousOrder = [], { regroup = true } = {}) {
|
|
32
|
-
const byId = new Map(sessions.map((session) => [session
|
|
37
|
+
const byId = new Map(sessions.map((session) => [activityCardKey(session), session]));
|
|
33
38
|
const surviving = previousOrder.filter((id) => byId.has(id));
|
|
34
39
|
const known = new Set(surviving);
|
|
35
40
|
for (const session of sessions) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const id = activityCardKey(session);
|
|
42
|
+
if (!id || known.has(id)) continue;
|
|
43
|
+
surviving.push(id);
|
|
44
|
+
known.add(id);
|
|
39
45
|
}
|
|
40
46
|
if (!regroup) return surviving;
|
|
41
47
|
|
|
@@ -45,16 +51,16 @@ export function reconcileActivityOrder(sessions, previousOrder = [], { regroup =
|
|
|
45
51
|
const rightPriority = isMonitoringSession(right) ? 2 : isActiveSession(right) ? 1 : 0;
|
|
46
52
|
const groupDelta = rightPriority - leftPriority;
|
|
47
53
|
if (groupDelta) return groupDelta;
|
|
48
|
-
const leftPrior = priorIndex.get(left
|
|
49
|
-
const rightPrior = priorIndex.get(right
|
|
54
|
+
const leftPrior = priorIndex.get(activityCardKey(left));
|
|
55
|
+
const rightPrior = priorIndex.get(activityCardKey(right));
|
|
50
56
|
if (leftPrior != null || rightPrior != null) {
|
|
51
57
|
return (leftPrior ?? Number.MAX_SAFE_INTEGER) - (rightPrior ?? Number.MAX_SAFE_INTEGER);
|
|
52
58
|
}
|
|
53
59
|
const updateDelta = Number(right.updatedAt ?? 0) - Number(left.updatedAt ?? 0);
|
|
54
60
|
if (updateDelta) return updateDelta;
|
|
55
|
-
return left.
|
|
61
|
+
return activityCardKey(left).localeCompare(activityCardKey(right));
|
|
56
62
|
});
|
|
57
|
-
return ordered.map(
|
|
63
|
+
return ordered.map(activityCardKey).filter((id) => byId.has(id));
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/** @param {string[]} order @param {string} sessionId @param {-1 | 1} delta */
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "recognizes terminal-based coding agents and shapes them into activity-strip session records"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "adding an agent CLI, changing agent card fields, or changing agent title matching"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Terminal-based coding agents that occupy a Ghostty tab the way Pi does. Recognition is by the
|
|
11
|
+
* executable name of the process that owns the terminal, never by a window title, so a tab is
|
|
12
|
+
* admitted on process evidence alone.
|
|
13
|
+
* @type {ReadonlyArray<{kind: string; label: string; commands: readonly string[]}>}
|
|
14
|
+
*/
|
|
15
|
+
export const AGENT_CATALOG = Object.freeze([
|
|
16
|
+
{ kind: "claude", label: "Claude Code", commands: Object.freeze(["claude"]) },
|
|
17
|
+
{ kind: "codex", label: "Codex", commands: Object.freeze(["codex"]) },
|
|
18
|
+
{ kind: "gemini", label: "Gemini", commands: Object.freeze(["gemini"]) },
|
|
19
|
+
{ kind: "amp", label: "Amp", commands: Object.freeze(["amp"]) },
|
|
20
|
+
{ kind: "aider", label: "Aider", commands: Object.freeze(["aider"]) },
|
|
21
|
+
{ kind: "opencode", label: "opencode", commands: Object.freeze(["opencode"]) },
|
|
22
|
+
{ kind: "crush", label: "Crush", commands: Object.freeze(["crush"]) },
|
|
23
|
+
{ kind: "goose", label: "Goose", commands: Object.freeze(["goose"]) },
|
|
24
|
+
{ kind: "cursor", label: "Cursor Agent", commands: Object.freeze(["cursor-agent"]) },
|
|
25
|
+
{ kind: "zcode", label: "zcode", commands: Object.freeze(["zcode"]) },
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Pi publishes its own telemetry, so its processes are never discovered this way. */
|
|
29
|
+
const EXCLUDED_COMMANDS = new Set(["pi", "node", "bash", "sh", "zsh", "fish", "python3"]);
|
|
30
|
+
/**
|
|
31
|
+
* Executables that only ever host another program. When one of these is the process name, the
|
|
32
|
+
* command line names the real agent, so it is worth the extra read to look.
|
|
33
|
+
*/
|
|
34
|
+
const RUNTIME_COMMANDS = [/^node/i, /^python/i, /^(bash|sh|zsh|fish|dash|env)$/i, /^(deno|bun)$/i];
|
|
35
|
+
|
|
36
|
+
/** @param {unknown} command */
|
|
37
|
+
export function isRuntimeCommand(command) {
|
|
38
|
+
const name = path.basename(String(command ?? "").trim());
|
|
39
|
+
return name.length > 0 && RUNTIME_COMMANDS.some((pattern) => pattern.test(name));
|
|
40
|
+
}
|
|
41
|
+
export const AGENT_ACTIVE_WINDOW_MS = 45_000;
|
|
42
|
+
|
|
43
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
44
|
+
export function agentCatalogFor(env = process.env) {
|
|
45
|
+
const disabled = new Set(
|
|
46
|
+
String(env.PI_ACTIVITY_STRIP_AGENT_KINDS_DISABLED ?? "")
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((value) => value.trim().toLowerCase())
|
|
49
|
+
.filter(Boolean),
|
|
50
|
+
);
|
|
51
|
+
return AGENT_CATALOG.filter((agent) => !disabled.has(agent.kind));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Classify one process by the executable it runs. `command` is the kernel's comm value, which the
|
|
56
|
+
* kernel truncates to 15 characters and which names the runtime rather than the agent when the
|
|
57
|
+
* agent is launched through one; the command line entries name the agent in that case.
|
|
58
|
+
* @param {{command?: unknown; argv0?: unknown; argv1?: unknown; env?: NodeJS.ProcessEnv}} process
|
|
59
|
+
*/
|
|
60
|
+
export function classifyAgentProcess({ command, argv0, argv1, env = process.env } = {}) {
|
|
61
|
+
const names = [command, argv0, argv1]
|
|
62
|
+
.map((value) => path.basename(String(value ?? "").trim()).toLowerCase())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
if (names.length === 0 || names.every((name) => EXCLUDED_COMMANDS.has(name))) return null;
|
|
65
|
+
for (const agent of agentCatalogFor(env)) {
|
|
66
|
+
if (agent.commands.some((candidate) => names.includes(candidate))) return agent;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {unknown} cwd */
|
|
72
|
+
export function repoLabelFor(cwd) {
|
|
73
|
+
const normalized = String(cwd ?? "").trim();
|
|
74
|
+
if (!normalized || normalized === "/") return "";
|
|
75
|
+
return path.basename(normalized);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Shape one discovered agent tab into the record the card projection consumes. The record carries a
|
|
80
|
+
* terminal identity, so it groups and places exactly like a Pi terminal card, but its session id is
|
|
81
|
+
* namespaced by agent kind and can never be mistaken for a Pi session id.
|
|
82
|
+
* @param {{
|
|
83
|
+
* kind: string;
|
|
84
|
+
* label: string;
|
|
85
|
+
* processId: number;
|
|
86
|
+
* cwd: string;
|
|
87
|
+
* terminalKey: string;
|
|
88
|
+
* terminalFamily: string;
|
|
89
|
+
* terminalSurfaceId: string;
|
|
90
|
+
* sessionKey?: string;
|
|
91
|
+
* titleSuffix?: string;
|
|
92
|
+
* startedAt: number;
|
|
93
|
+
* lastEventAt?: number;
|
|
94
|
+
* now?: number;
|
|
95
|
+
* telemetry?: Partial<import("./claude-transcript.mjs").ClaudeTelemetry> | null;
|
|
96
|
+
* }} tab
|
|
97
|
+
*/
|
|
98
|
+
export function agentSessionRecord({
|
|
99
|
+
kind,
|
|
100
|
+
label,
|
|
101
|
+
processId,
|
|
102
|
+
cwd,
|
|
103
|
+
terminalKey,
|
|
104
|
+
terminalFamily,
|
|
105
|
+
terminalSurfaceId,
|
|
106
|
+
sessionKey = "",
|
|
107
|
+
titleSuffix = "",
|
|
108
|
+
startedAt,
|
|
109
|
+
lastEventAt = 0,
|
|
110
|
+
now = Date.now(),
|
|
111
|
+
telemetry = null,
|
|
112
|
+
}) {
|
|
113
|
+
// Process start times and file modification times are fractional milliseconds, while the panel
|
|
114
|
+
// protocol carries whole-millisecond integers, so every timestamp is rounded at the boundary.
|
|
115
|
+
const startedAtMs = Math.round(startedAt) || 0;
|
|
116
|
+
const lastEventMs = Math.round(lastEventAt) || 0;
|
|
117
|
+
const nowMs = Math.round(now) || 0;
|
|
118
|
+
const activity = lastEventMs > 0 ? lastEventMs : startedAtMs;
|
|
119
|
+
// Reported activity decides liveness. A published state only counts while the transcript is
|
|
120
|
+
// still moving; a stale "tool" record is not evidence that a tool is running right now.
|
|
121
|
+
const recent = lastEventMs > 0 && nowMs - lastEventMs <= AGENT_ACTIVE_WINDOW_MS;
|
|
122
|
+
const reported = String(telemetry?.state ?? "");
|
|
123
|
+
// "waiting" and "error" describe a session that is stopped on purpose, so they survive the
|
|
124
|
+
// liveness window: a permission prompt can sit unanswered for a long time and is still true.
|
|
125
|
+
const sticky = reported === "waiting" || reported === "error";
|
|
126
|
+
const busy = reported === "tool" || reported === "thinking";
|
|
127
|
+
const active = sticky || (recent && (busy || !reported));
|
|
128
|
+
const state = sticky ? reported : active ? (busy ? reported : "thinking") : "idle";
|
|
129
|
+
const toolName = active ? String(telemetry?.toolName ?? "") : "";
|
|
130
|
+
const toolTarget = active ? String(telemetry?.toolTarget ?? "") : "";
|
|
131
|
+
const detail = [toolTarget, String(telemetry?.assistantPreview ?? ""), cwd].find(Boolean) ?? "";
|
|
132
|
+
return {
|
|
133
|
+
sessionId: `agent:${kind}:${sessionKey || processId}`,
|
|
134
|
+
publisherId: `agent-scan:${processId}`,
|
|
135
|
+
publisherSequence: 0,
|
|
136
|
+
processId,
|
|
137
|
+
agentKind: kind,
|
|
138
|
+
agentSessionKey: sessionKey,
|
|
139
|
+
terminalKind: "ghostty-surface",
|
|
140
|
+
terminalKey,
|
|
141
|
+
terminalFamily,
|
|
142
|
+
terminalSurfaceId,
|
|
143
|
+
titleSuffix,
|
|
144
|
+
cwd,
|
|
145
|
+
repoLabel: repoLabelFor(cwd),
|
|
146
|
+
sessionName: "",
|
|
147
|
+
agentLabel: label,
|
|
148
|
+
phase: String(telemetry?.title ?? "").trim() || label,
|
|
149
|
+
detail,
|
|
150
|
+
assistantPreview: String(telemetry?.assistantPreview ?? ""),
|
|
151
|
+
toolName,
|
|
152
|
+
toolTarget,
|
|
153
|
+
state,
|
|
154
|
+
turnIndex: Number(telemetry?.turnIndex ?? 0) || 0,
|
|
155
|
+
updatedAt: nowMs,
|
|
156
|
+
lastEventAt: activity,
|
|
157
|
+
startedAt: startedAtMs,
|
|
158
|
+
agentStartedAt: active ? activity : null,
|
|
159
|
+
agentActive: active,
|
|
160
|
+
lastPromptPreview: String(telemetry?.lastPrompt ?? ""),
|
|
161
|
+
errorMessage: "",
|
|
162
|
+
};
|
|
163
|
+
}
|