@jaggerxtrm/pi-extensions 0.7.22 → 0.7.24
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 +1 -0
- package/extensions/README.md +18 -0
- package/extensions/serena-pool/index.ts +322 -29
- package/extensions/serena-pool/test/e2e.ts +229 -0
- package/extensions/xtrm-ui/format.ts +3 -1
- package/extensions/xtrm-ui/index.ts +26 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,3 +48,4 @@ Notable bundled extensions include:
|
|
|
48
48
|
|
|
49
49
|
- `xtrm-ui` — XTRM Pi chrome, native tool summaries, selectable external tool chrome (`/xtrm-ui chrome background|box`).
|
|
50
50
|
- `sp-terminal-overlay` — `/sp-feed` streaming overlay, `/sp-ps` snapshot overlay, and `/xtrm-terminal` shell overlay for specialist monitoring.
|
|
51
|
+
- `serena-pool` — shared Serena MCP daemon per repo root; sets `SERENA_MCP_PORT` for `pi-serena-tools`, reuses daemons across sessions, and safely cleans up owned orphan process groups.
|
package/extensions/README.md
CHANGED
|
@@ -15,3 +15,21 @@ Commands:
|
|
|
15
15
|
- `/xtrm-terminal <command>` — opens an arbitrary shell command in an overlay.
|
|
16
16
|
|
|
17
17
|
Keys: `Esc`/`q` close, `r` restart, arrows/page keys scroll.
|
|
18
|
+
|
|
19
|
+
## serena-pool
|
|
20
|
+
|
|
21
|
+
Shared Serena daemon pool for Pi sessions.
|
|
22
|
+
|
|
23
|
+
Behavior:
|
|
24
|
+
|
|
25
|
+
- Resolves the current git repo root on `session_start`.
|
|
26
|
+
- Maps each repo root to a deterministic local port.
|
|
27
|
+
- Reuses an existing Serena MCP daemon when the port is already listening.
|
|
28
|
+
- Spawns Serena via `uvx` when no daemon is listening and exports `SERENA_MCP_PORT` for `pi-serena-tools`.
|
|
29
|
+
- Persists ownership state under `/tmp/serena-pool` and reaps only owned orphan process groups from dead recorded daemons.
|
|
30
|
+
|
|
31
|
+
Debugging:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
DEBUG=serena-pool pi
|
|
35
|
+
```
|
|
@@ -1,23 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* serena-pool — shared Serena daemon per repo root
|
|
2
|
+
* serena-pool — shared Serena daemon per repo root with ownership-based cleanup
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* on that port
|
|
4
|
+
* On session_start:
|
|
5
|
+
* 1. Resolve repo root from cwd → deterministic port via hash.
|
|
6
|
+
* 2. If a Serena is already listening on that port → reuse it, set SERENA_MCP_PORT.
|
|
7
|
+
* 3. Otherwise acquire a per-port file lock and:
|
|
8
|
+
* - Read the recorded state (pid, pgid, startTime, instanceId) from /tmp.
|
|
9
|
+
* - If the previously-recorded Serena process is dead → kill its process
|
|
10
|
+
* group (SIGTERM then SIGKILL) to reap orphaned LSP children.
|
|
11
|
+
* - Spawn a fresh Serena (detached → new process group, pgid == pid).
|
|
12
|
+
* - Persist new state to /tmp.
|
|
13
|
+
* - Wait until the port answers, then release the lock.
|
|
7
14
|
*
|
|
8
|
-
* pi-serena-tools reads SERENA_MCP_PORT lazily
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* pi-serena-tools reads SERENA_MCP_PORT lazily and reuses the daemon; its
|
|
16
|
+
* own stopServer() is a no-op because it never held the child handle.
|
|
17
|
+
*
|
|
18
|
+
* Orphan cleanup is by process-group ownership only — never by path matching.
|
|
19
|
+
* We only signal processes whose pgid matches the recorded pgid AND whose
|
|
20
|
+
* controlling Serena is verifiably dead (pid+startTime check). Process-group
|
|
21
|
+
* kills are bounded to ours, so editor LSPs / tests / hooks are never touched.
|
|
11
22
|
*/
|
|
12
23
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import { spawn } from "node:child_process";
|
|
24
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
14
25
|
import { connect } from "node:net";
|
|
15
|
-
import {
|
|
26
|
+
import { existsSync, mkdirSync, openSync, writeSync, closeSync, readFileSync, unlinkSync, realpathSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
16
30
|
|
|
17
31
|
const POOL_PORT_MIN = 40000;
|
|
18
|
-
const POOL_PORT_RANGE = 5000; // 40000–44999
|
|
32
|
+
const POOL_PORT_RANGE = 5000; // ports 40000–44999
|
|
19
33
|
const STARTUP_TIMEOUT_MS = 15_000;
|
|
20
34
|
const POLL_INTERVAL_MS = 300;
|
|
35
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
36
|
+
const ORPHAN_TERM_GRACE_MS = 2_000;
|
|
37
|
+
|
|
38
|
+
export const STATE_DIR = join(tmpdir(), "serena-pool");
|
|
39
|
+
|
|
40
|
+
const DEBUG_ENABLED = (process.env.DEBUG ?? "").split(/[\s,]+/).includes("serena-pool");
|
|
41
|
+
function debug(msg: string, ...args: unknown[]): void {
|
|
42
|
+
if (DEBUG_ENABLED) console.error(`[serena-pool] ${msg}`, ...args);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type PoolState = {
|
|
46
|
+
pid: number;
|
|
47
|
+
pgid: number;
|
|
48
|
+
startTime: string | null;
|
|
49
|
+
instanceId: string;
|
|
50
|
+
projectRoot: string;
|
|
51
|
+
port: number;
|
|
52
|
+
spawnedAt: number;
|
|
53
|
+
};
|
|
21
54
|
|
|
22
55
|
function hashToPort(s: string): number {
|
|
23
56
|
let h = 5381;
|
|
@@ -28,18 +61,175 @@ function hashToPort(s: string): number {
|
|
|
28
61
|
return POOL_PORT_MIN + (h % POOL_PORT_RANGE);
|
|
29
62
|
}
|
|
30
63
|
|
|
64
|
+
function ensureStateDir(): void {
|
|
65
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stateFilePath(port: number): string {
|
|
69
|
+
return join(STATE_DIR, `pool-${port}.json`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lockFilePath(port: number): string {
|
|
73
|
+
return join(STATE_DIR, `pool-${port}.lock`);
|
|
74
|
+
}
|
|
75
|
+
|
|
31
76
|
function getRepoRoot(cwd: string): string {
|
|
77
|
+
let root = cwd;
|
|
32
78
|
try {
|
|
33
|
-
|
|
79
|
+
const out = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
34
80
|
cwd,
|
|
35
81
|
encoding: "utf8",
|
|
36
82
|
stdio: ["ignore", "pipe", "ignore"],
|
|
37
83
|
}).trim();
|
|
84
|
+
if (out) root = out;
|
|
85
|
+
} catch {
|
|
86
|
+
/* not a git repo — use cwd */
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return realpathSync(root);
|
|
90
|
+
} catch {
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isPidAlive(pid: number): boolean {
|
|
96
|
+
if (!pid || pid <= 0) return false;
|
|
97
|
+
try {
|
|
98
|
+
process.kill(pid, 0);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (err: unknown) {
|
|
101
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
102
|
+
if (code === "EPERM") return true; // exists, just not signalable by us
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Linux: /proc/<pid>/stat field 22 (starttime). macOS/other: `ps -o lstart=`. */
|
|
108
|
+
function getProcessStartTime(pid: number): string | null {
|
|
109
|
+
if (!pid || pid <= 0) return null;
|
|
110
|
+
try {
|
|
111
|
+
if (process.platform === "linux") {
|
|
112
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
113
|
+
// pid (comm) state ... — comm may contain spaces/parens; split after last ')'
|
|
114
|
+
const lastParen = stat.lastIndexOf(")");
|
|
115
|
+
if (lastParen < 0) return null;
|
|
116
|
+
const fields = stat.slice(lastParen + 2).split(" ");
|
|
117
|
+
// After (comm), fields[0]=state, fields[19]=starttime (clock ticks since boot)
|
|
118
|
+
return fields[19] ?? null;
|
|
119
|
+
}
|
|
120
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
123
|
+
}).trim();
|
|
124
|
+
return out || null;
|
|
38
125
|
} catch {
|
|
39
|
-
return
|
|
126
|
+
return null;
|
|
40
127
|
}
|
|
41
128
|
}
|
|
42
129
|
|
|
130
|
+
/** Verify that `pid` is alive AND its start time still matches `recorded`. */
|
|
131
|
+
function isSameProcess(pid: number, recordedStartTime: string | null): boolean {
|
|
132
|
+
if (!isPidAlive(pid)) return false;
|
|
133
|
+
if (recordedStartTime == null) return true; // we never captured one — trust pid only
|
|
134
|
+
const current = getProcessStartTime(pid);
|
|
135
|
+
return current != null && current === recordedStartTime;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Enumerate all processes whose pgid matches the given value (excluding us). */
|
|
139
|
+
function findProcessesByPgid(pgid: number): Array<{ pid: number; comm: string }> {
|
|
140
|
+
if (!pgid || pgid <= 0) return [];
|
|
141
|
+
try {
|
|
142
|
+
const out = execFileSync("ps", ["-e", "-o", "pid=,pgid=,comm="], {
|
|
143
|
+
encoding: "utf8",
|
|
144
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
145
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
146
|
+
});
|
|
147
|
+
const result: Array<{ pid: number; comm: string }> = [];
|
|
148
|
+
for (const line of out.split("\n")) {
|
|
149
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
150
|
+
if (!m) continue;
|
|
151
|
+
const pid = Number(m[1]);
|
|
152
|
+
const procPgid = Number(m[2]);
|
|
153
|
+
if (procPgid === pgid && pid !== process.pid) {
|
|
154
|
+
result.push({ pid, comm: m[3].trim() });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function tryKill(pid: number, signal: NodeJS.Signals): void {
|
|
164
|
+
try {
|
|
165
|
+
process.kill(pid, signal);
|
|
166
|
+
} catch {
|
|
167
|
+
/* already gone */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function readState(port: number): PoolState | null {
|
|
172
|
+
try {
|
|
173
|
+
const raw = readFileSync(stateFilePath(port), "utf8");
|
|
174
|
+
const parsed = JSON.parse(raw) as PoolState;
|
|
175
|
+
if (typeof parsed.pid === "number" && typeof parsed.pgid === "number") return parsed;
|
|
176
|
+
return null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function writeState(state: PoolState): void {
|
|
183
|
+
ensureStateDir();
|
|
184
|
+
const path = stateFilePath(state.port);
|
|
185
|
+
const fd = openSync(path, "w", 0o600);
|
|
186
|
+
try {
|
|
187
|
+
writeSync(fd, JSON.stringify(state, null, 2));
|
|
188
|
+
} finally {
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function removeState(port: number): void {
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(stateFilePath(port));
|
|
196
|
+
} catch {
|
|
197
|
+
/* ignore */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type Lock = { release: () => void };
|
|
202
|
+
|
|
203
|
+
async function acquireLock(port: number, timeoutMs: number): Promise<Lock> {
|
|
204
|
+
ensureStateDir();
|
|
205
|
+
const path = lockFilePath(port);
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
while (Date.now() < deadline) {
|
|
208
|
+
try {
|
|
209
|
+
const fd = openSync(path, "wx", 0o600);
|
|
210
|
+
writeSync(fd, String(process.pid));
|
|
211
|
+
closeSync(fd);
|
|
212
|
+
return {
|
|
213
|
+
release: () => {
|
|
214
|
+
try { unlinkSync(path); } catch { /* ignore */ }
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
} catch (err: unknown) {
|
|
218
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
219
|
+
if (code !== "EEXIST") throw err;
|
|
220
|
+
// Held by someone — check if that someone is still alive
|
|
221
|
+
let heldByPid = 0;
|
|
222
|
+
try { heldByPid = parseInt(readFileSync(path, "utf8").trim(), 10) || 0; } catch { /* ignore */ }
|
|
223
|
+
if (heldByPid > 0 && !isPidAlive(heldByPid)) {
|
|
224
|
+
try { unlinkSync(path); } catch { /* race ok */ }
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`serena-pool: could not acquire lock ${path} within ${timeoutMs}ms`);
|
|
231
|
+
}
|
|
232
|
+
|
|
43
233
|
function isPortListening(port: number): Promise<boolean> {
|
|
44
234
|
return new Promise((resolve) => {
|
|
45
235
|
const socket = connect({ host: "127.0.0.1", port }, () => {
|
|
@@ -54,8 +244,8 @@ function isPortListening(port: number): Promise<boolean> {
|
|
|
54
244
|
});
|
|
55
245
|
}
|
|
56
246
|
|
|
57
|
-
async function waitForPort(port: number): Promise<boolean> {
|
|
58
|
-
const deadline = Date.now() +
|
|
247
|
+
async function waitForPort(port: number, timeoutMs: number): Promise<boolean> {
|
|
248
|
+
const deadline = Date.now() + timeoutMs;
|
|
59
249
|
while (Date.now() < deadline) {
|
|
60
250
|
if (await isPortListening(port)) return true;
|
|
61
251
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
@@ -63,10 +253,25 @@ async function waitForPort(port: number): Promise<boolean> {
|
|
|
63
253
|
return false;
|
|
64
254
|
}
|
|
65
255
|
|
|
66
|
-
|
|
67
|
-
|
|
256
|
+
/**
|
|
257
|
+
* Reap orphaned LSP children left by a dead Serena daemon.
|
|
258
|
+
* Safety: we only signal processes whose pgid matches the recorded one AND
|
|
259
|
+
* whose controlling Serena (pid+startTime) is verifiably dead. The kernel
|
|
260
|
+
* guarantees nothing else can be in that process group unless it was a
|
|
261
|
+
* descendant of our spawned Serena.
|
|
262
|
+
*/
|
|
263
|
+
async function reapOrphansIfDaemonDead(state: PoolState): Promise<void> {
|
|
264
|
+
if (isSameProcess(state.pid, state.startTime)) return; // daemon still alive — abort
|
|
265
|
+
const candidates = findProcessesByPgid(state.pgid);
|
|
266
|
+
if (candidates.length === 0) return;
|
|
267
|
+
for (const c of candidates) tryKill(c.pid, "SIGTERM");
|
|
268
|
+
await new Promise((r) => setTimeout(r, ORPHAN_TERM_GRACE_MS));
|
|
269
|
+
const stragglers = findProcessesByPgid(state.pgid);
|
|
270
|
+
for (const s of stragglers) tryKill(s.pid, "SIGKILL");
|
|
271
|
+
}
|
|
68
272
|
|
|
69
|
-
|
|
273
|
+
async function spawnSerena(projectRoot: string, port: number, instanceId: string): Promise<number> {
|
|
274
|
+
const child = spawn(
|
|
70
275
|
"uvx",
|
|
71
276
|
[
|
|
72
277
|
"--from", "git+https://github.com/oraios/serena",
|
|
@@ -78,33 +283,121 @@ async function ensureSerenaRunning(projectRoot: string, port: number): Promise<v
|
|
|
78
283
|
],
|
|
79
284
|
{
|
|
80
285
|
cwd: projectRoot,
|
|
81
|
-
env: {
|
|
286
|
+
env: {
|
|
287
|
+
...process.env,
|
|
288
|
+
SERENA_POOL_INSTANCE_ID: instanceId,
|
|
289
|
+
SERENA_POOL_ROOT: projectRoot,
|
|
290
|
+
SERENA_POOL_PORT: String(port),
|
|
291
|
+
},
|
|
82
292
|
stdio: "ignore",
|
|
83
293
|
detached: true,
|
|
84
294
|
},
|
|
85
295
|
);
|
|
86
|
-
|
|
296
|
+
child.unref();
|
|
297
|
+
if (!child.pid) throw new Error("serena-pool: spawn returned no pid");
|
|
298
|
+
return child.pid;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function ensureSerenaForRoot(projectRoot: string): Promise<number | null> {
|
|
302
|
+
const port = hashToPort(projectRoot);
|
|
303
|
+
debug(`ensureSerenaForRoot root=${projectRoot} port=${port}`);
|
|
304
|
+
|
|
305
|
+
// Fast path: someone already serving on this port. Trust it.
|
|
306
|
+
if (await isPortListening(port)) {
|
|
307
|
+
debug(`fast path: port ${port} already listening`);
|
|
308
|
+
return port;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
debug(`acquiring lock for port ${port}`);
|
|
312
|
+
const lock = await acquireLock(port, LOCK_TIMEOUT_MS);
|
|
313
|
+
try {
|
|
314
|
+
// Re-check under the lock — another concurrent spawner may have won.
|
|
315
|
+
if (await isPortListening(port)) {
|
|
316
|
+
debug(`raced: port ${port} now listening under lock`);
|
|
317
|
+
return port;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Reap orphans from a previously-recorded dead daemon, if any.
|
|
321
|
+
const prior = readState(port);
|
|
322
|
+
if (prior) {
|
|
323
|
+
debug(`prior state found pid=${prior.pid} pgid=${prior.pgid}`);
|
|
324
|
+
const alive = isSameProcess(prior.pid, prior.startTime);
|
|
325
|
+
if (alive) {
|
|
326
|
+
debug(`prior daemon still alive — skipping reap (port may be transient down)`);
|
|
327
|
+
} else {
|
|
328
|
+
const candidates = findProcessesByPgid(prior.pgid);
|
|
329
|
+
debug(`prior daemon dead; reaping ${candidates.length} orphan(s) in pgid=${prior.pgid}`);
|
|
330
|
+
await reapOrphansIfDaemonDead(prior);
|
|
331
|
+
}
|
|
332
|
+
removeState(port);
|
|
333
|
+
} else {
|
|
334
|
+
debug(`no prior state`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Spawn fresh Serena. Detached → new process group, pgid == pid.
|
|
338
|
+
const instanceId = randomUUID();
|
|
339
|
+
const pid = await spawnSerena(projectRoot, port, instanceId);
|
|
340
|
+
const startTime = getProcessStartTime(pid);
|
|
341
|
+
debug(`spawned Serena pid=${pid} pgid=${pid} startTime=${startTime}`);
|
|
342
|
+
writeState({
|
|
343
|
+
pid,
|
|
344
|
+
pgid: pid, // detached process is a new group leader
|
|
345
|
+
startTime,
|
|
346
|
+
instanceId,
|
|
347
|
+
projectRoot,
|
|
348
|
+
port,
|
|
349
|
+
spawnedAt: Date.now(),
|
|
350
|
+
});
|
|
87
351
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
352
|
+
const ready = await waitForPort(port, STARTUP_TIMEOUT_MS);
|
|
353
|
+
if (!ready) {
|
|
354
|
+
console.warn(`[serena-pool] Serena did not become healthy on port ${port} within ${STARTUP_TIMEOUT_MS}ms (pid=${pid})`);
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
debug(`ready on port ${port}`);
|
|
358
|
+
return port;
|
|
359
|
+
} finally {
|
|
360
|
+
lock.release();
|
|
361
|
+
debug(`released lock for port ${port}`);
|
|
91
362
|
}
|
|
92
363
|
}
|
|
93
364
|
|
|
365
|
+
// Internals exported for testing.
|
|
366
|
+
export const __internals = {
|
|
367
|
+
hashToPort,
|
|
368
|
+
isPortListening,
|
|
369
|
+
isPidAlive,
|
|
370
|
+
isSameProcess,
|
|
371
|
+
getProcessStartTime,
|
|
372
|
+
findProcessesByPgid,
|
|
373
|
+
getRepoRoot,
|
|
374
|
+
readState,
|
|
375
|
+
writeState,
|
|
376
|
+
removeState,
|
|
377
|
+
stateFilePath,
|
|
378
|
+
lockFilePath,
|
|
379
|
+
};
|
|
380
|
+
export type { PoolState };
|
|
381
|
+
|
|
94
382
|
export default function registerSerenaPool(pi: ExtensionAPI) {
|
|
95
383
|
pi.on("session_start", async (_event: unknown, ctx: any) => {
|
|
96
384
|
const cwd: string = ctx?.cwd ?? process.cwd();
|
|
97
|
-
|
|
98
|
-
|
|
385
|
+
let projectRoot: string;
|
|
386
|
+
try {
|
|
387
|
+
projectRoot = getRepoRoot(cwd);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.warn("[serena-pool] failed to resolve repo root:", err);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
99
392
|
|
|
100
393
|
try {
|
|
101
|
-
await
|
|
102
|
-
process.env.SERENA_MCP_PORT = String(port);
|
|
394
|
+
const port = await ensureSerenaForRoot(projectRoot);
|
|
395
|
+
if (port != null) process.env.SERENA_MCP_PORT = String(port);
|
|
103
396
|
} catch (err) {
|
|
104
|
-
console.warn("[serena-pool]
|
|
397
|
+
console.warn("[serena-pool] failed to ensure Serena running:", err);
|
|
105
398
|
}
|
|
106
399
|
});
|
|
107
400
|
|
|
108
|
-
// session_shutdown
|
|
109
|
-
//
|
|
401
|
+
// No session_shutdown handler — Serena persists as a daemon across sessions.
|
|
402
|
+
// Next session_start that finds the port dead will reap orphans via PGID.
|
|
110
403
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* E2E driver for serena-pool. Exercises the state machine without Pi.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* DEBUG=serena-pool npx tsx extensions/serena-pool/test/e2e.ts
|
|
7
|
+
* DEBUG=serena-pool bun extensions/serena-pool/test/e2e.ts
|
|
8
|
+
*
|
|
9
|
+
* Requires: `uvx` on PATH (the real Serena spawn path is exercised).
|
|
10
|
+
*
|
|
11
|
+
* Five scenarios:
|
|
12
|
+
* 1. cold start — clean slate → spawn, port listens, state written
|
|
13
|
+
* 2. warm reuse — second call → same pid, same state
|
|
14
|
+
* 3. dead recovery — kill Serena → next call spawns fresh
|
|
15
|
+
* 4. synthetic orphans — fake state pointing at a detached `sleep` group
|
|
16
|
+
* with dead leader → cleanup kills group members
|
|
17
|
+
* 5. concurrent spawn — 5 parallel calls → exactly one Serena alive
|
|
18
|
+
*/
|
|
19
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
20
|
+
import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
ensureSerenaForRoot,
|
|
27
|
+
STATE_DIR,
|
|
28
|
+
__internals as I,
|
|
29
|
+
type PoolState,
|
|
30
|
+
} from "../index.ts";
|
|
31
|
+
|
|
32
|
+
let testsPassed = 0;
|
|
33
|
+
let testsFailed = 0;
|
|
34
|
+
|
|
35
|
+
function assert(cond: unknown, msg: string): void {
|
|
36
|
+
if (cond) {
|
|
37
|
+
console.log(` ✓ ${msg}`);
|
|
38
|
+
testsPassed++;
|
|
39
|
+
} else {
|
|
40
|
+
console.log(` ✗ ${msg}`);
|
|
41
|
+
testsFailed++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function header(name: string): void {
|
|
46
|
+
console.log(`\n── ${name} ──`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cleanState(): void {
|
|
50
|
+
if (!existsSync(STATE_DIR)) return;
|
|
51
|
+
for (const f of readdirSync(STATE_DIR)) {
|
|
52
|
+
try { rmSync(join(STATE_DIR, f), { force: true }); } catch { /* ignore */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function killSerenaForPort(port: number): void {
|
|
57
|
+
const state = I.readState(port);
|
|
58
|
+
if (!state) return;
|
|
59
|
+
try { process.kill(-state.pgid, "SIGKILL"); } catch { /* ignore */ }
|
|
60
|
+
try { process.kill(state.pid, "SIGKILL"); } catch { /* ignore */ }
|
|
61
|
+
I.removeState(port);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function preflight(): boolean {
|
|
65
|
+
const uvx = spawnSync("which", ["uvx"], { encoding: "utf8" });
|
|
66
|
+
if (uvx.status !== 0 || !uvx.stdout.trim()) {
|
|
67
|
+
console.error("✗ uvx not on PATH — install uv first: https://docs.astral.sh/uv/");
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
console.log(`✓ uvx: ${uvx.stdout.trim()}`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function withTestRoot(fn: (root: string, port: number) => Promise<void>): Promise<void> {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), "serena-pool-test-"));
|
|
76
|
+
const port = I.hashToPort(root);
|
|
77
|
+
try {
|
|
78
|
+
cleanState();
|
|
79
|
+
await fn(root, port);
|
|
80
|
+
} finally {
|
|
81
|
+
killSerenaForPort(port);
|
|
82
|
+
rmSync(root, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function test1_coldStart(): Promise<void> {
|
|
87
|
+
header("1. cold start");
|
|
88
|
+
await withTestRoot(async (root, expectedPort) => {
|
|
89
|
+
const port = await ensureSerenaForRoot(root);
|
|
90
|
+
assert(port === expectedPort, `port returned (${port}) === hashToPort(root) (${expectedPort})`);
|
|
91
|
+
assert(port != null && (await I.isPortListening(port)), "port is listening");
|
|
92
|
+
const state = I.readState(port!);
|
|
93
|
+
assert(state != null, "state file written");
|
|
94
|
+
assert(state != null && I.isPidAlive(state.pid), "recorded Serena pid is alive");
|
|
95
|
+
assert(state != null && state.pgid === state.pid, "pgid == pid (detached)");
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function test2_warmReuse(): Promise<void> {
|
|
100
|
+
header("2. warm reuse");
|
|
101
|
+
await withTestRoot(async (root) => {
|
|
102
|
+
const port1 = await ensureSerenaForRoot(root);
|
|
103
|
+
const state1 = I.readState(port1!)!;
|
|
104
|
+
|
|
105
|
+
const port2 = await ensureSerenaForRoot(root);
|
|
106
|
+
const state2 = I.readState(port2!)!;
|
|
107
|
+
|
|
108
|
+
assert(port1 === port2, "same port across calls");
|
|
109
|
+
assert(state1.pid === state2.pid, `same pid (no respawn) — pid=${state1.pid}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function test3_deadDaemonRecovery(): Promise<void> {
|
|
114
|
+
header("3. dead-daemon recovery");
|
|
115
|
+
await withTestRoot(async (root) => {
|
|
116
|
+
const port = await ensureSerenaForRoot(root);
|
|
117
|
+
const before = I.readState(port!)!;
|
|
118
|
+
assert(I.isPidAlive(before.pid), `Serena up (pid=${before.pid})`);
|
|
119
|
+
|
|
120
|
+
// Kill the whole process group ungracefully
|
|
121
|
+
try { process.kill(-before.pgid, "SIGKILL"); } catch { /* ignore */ }
|
|
122
|
+
try { process.kill(before.pid, "SIGKILL"); } catch { /* ignore */ }
|
|
123
|
+
await sleep(800);
|
|
124
|
+
assert(!I.isPidAlive(before.pid), "Serena killed");
|
|
125
|
+
assert(!(await I.isPortListening(port!)), "port no longer listening");
|
|
126
|
+
|
|
127
|
+
const port2 = await ensureSerenaForRoot(root);
|
|
128
|
+
const after = I.readState(port2!)!;
|
|
129
|
+
assert(after.pid !== before.pid, `fresh spawn — new pid=${after.pid}`);
|
|
130
|
+
assert(I.isPidAlive(after.pid), "fresh Serena alive");
|
|
131
|
+
assert(await I.isPortListening(port2!), "port listening again");
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function test4_syntheticOrphanCleanup(): Promise<void> {
|
|
136
|
+
header("4. synthetic orphan cleanup");
|
|
137
|
+
await withTestRoot(async (root, port) => {
|
|
138
|
+
// Spawn `bash -c "sleep 9999 & sleep 9999 & wait"` detached so bash is a new
|
|
139
|
+
// group leader (pgid == bash pid). The two sleeps inherit that pgid.
|
|
140
|
+
const fake = spawn(
|
|
141
|
+
"bash",
|
|
142
|
+
["-c", "sleep 9999 & sleep 9999 & wait"],
|
|
143
|
+
{ detached: true, stdio: "ignore" },
|
|
144
|
+
);
|
|
145
|
+
fake.unref();
|
|
146
|
+
if (!fake.pid) throw new Error("could not spawn fake daemon");
|
|
147
|
+
const fakePid = fake.pid;
|
|
148
|
+
const fakePgid = fakePid;
|
|
149
|
+
await sleep(300);
|
|
150
|
+
|
|
151
|
+
const orphansBefore = I.findProcessesByPgid(fakePgid);
|
|
152
|
+
assert(orphansBefore.length >= 2, `2+ children in fake pgid before — found ${orphansBefore.length}`);
|
|
153
|
+
|
|
154
|
+
// Inject fake state matching this group, then kill the parent bash so
|
|
155
|
+
// children become orphans (reparented to PID 1, pgid retained).
|
|
156
|
+
const fakeStart = I.getProcessStartTime(fakePid);
|
|
157
|
+
const state: PoolState = {
|
|
158
|
+
pid: fakePid, pgid: fakePgid, startTime: fakeStart,
|
|
159
|
+
instanceId: "synthetic-test", projectRoot: root, port,
|
|
160
|
+
spawnedAt: Date.now(),
|
|
161
|
+
};
|
|
162
|
+
I.writeState(state);
|
|
163
|
+
process.kill(fakePid, "SIGKILL");
|
|
164
|
+
await sleep(500);
|
|
165
|
+
assert(!I.isPidAlive(fakePid), "fake daemon killed");
|
|
166
|
+
const stillOrphaned = I.findProcessesByPgid(fakePgid);
|
|
167
|
+
assert(stillOrphaned.length >= 2, `children survived parent death — orphans=${stillOrphaned.length}`);
|
|
168
|
+
|
|
169
|
+
// Trigger cleanup via the real code path (spawns a real Serena afterwards).
|
|
170
|
+
await ensureSerenaForRoot(root);
|
|
171
|
+
await sleep(500); // allow SIGTERM/SIGKILL fan-out to land
|
|
172
|
+
|
|
173
|
+
const orphansAfter = I.findProcessesByPgid(fakePgid);
|
|
174
|
+
assert(orphansAfter.length === 0, `orphans reaped — remaining=${orphansAfter.length}`);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function test5_concurrentSpawn(): Promise<void> {
|
|
179
|
+
header("5. concurrent spawn");
|
|
180
|
+
await withTestRoot(async (root) => {
|
|
181
|
+
const results = await Promise.all(
|
|
182
|
+
Array.from({ length: 5 }, () => ensureSerenaForRoot(root)),
|
|
183
|
+
);
|
|
184
|
+
const unique = new Set(results);
|
|
185
|
+
assert(unique.size === 1, `all 5 calls returned the same port — got ${unique.size} unique`);
|
|
186
|
+
|
|
187
|
+
// Count actual Serena processes for this port.
|
|
188
|
+
const port = results[0]!;
|
|
189
|
+
const state = I.readState(port)!;
|
|
190
|
+
assert(I.isPidAlive(state.pid), "one Serena alive");
|
|
191
|
+
|
|
192
|
+
// Count actual TCP listeners on the port — uvx spawns a python child, so a
|
|
193
|
+
// command-line pattern match would over-count. Only one process binds LISTEN.
|
|
194
|
+
const ssOut = spawnSync("ss", ["-tlnpH"], { encoding: "utf8" }).stdout;
|
|
195
|
+
const listeners = ssOut.split("\n").filter((l) => l.includes(`:${port} `)).length;
|
|
196
|
+
assert(listeners === 1, `exactly one TCP listener on port ${port} — found ${listeners}`);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function main(): Promise<void> {
|
|
201
|
+
console.log("serena-pool e2e driver\n");
|
|
202
|
+
if (!preflight()) process.exit(2);
|
|
203
|
+
|
|
204
|
+
const tests = [
|
|
205
|
+
test1_coldStart,
|
|
206
|
+
test2_warmReuse,
|
|
207
|
+
test3_deadDaemonRecovery,
|
|
208
|
+
test4_syntheticOrphanCleanup,
|
|
209
|
+
test5_concurrentSpawn,
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const t of tests) {
|
|
213
|
+
try {
|
|
214
|
+
await t();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error(` ✗ test threw: ${err instanceof Error ? err.message : err}`);
|
|
217
|
+
testsFailed++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(`\n────────────────────────────────`);
|
|
222
|
+
console.log(`passed: ${testsPassed} failed: ${testsFailed}`);
|
|
223
|
+
process.exit(testsFailed > 0 ? 1 : 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main().catch((err) => {
|
|
227
|
+
console.error("fatal:", err);
|
|
228
|
+
process.exit(2);
|
|
229
|
+
});
|
|
@@ -258,6 +258,8 @@ export function formatLineLabel(count: number, noun: string): string {
|
|
|
258
258
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
export const TOOL_ROW_MARKER = "›";
|
|
262
|
+
|
|
261
263
|
const TOOL_SUMMARY_SUBJECT_MAX = 34;
|
|
262
264
|
const TOOL_SUMMARY_META_MAX = 34;
|
|
263
265
|
|
|
@@ -275,7 +277,7 @@ export function renderToolSummary(
|
|
|
275
277
|
: "muted";
|
|
276
278
|
const compactSubject = subject ? shortenCommand(subject, TOOL_SUMMARY_SUBJECT_MAX) : undefined;
|
|
277
279
|
const compactMeta = meta ? shortenCommand(meta, TOOL_SUMMARY_META_MAX) : undefined;
|
|
278
|
-
let text = `${theme.fg(color,
|
|
280
|
+
let text = `${theme.fg(color, TOOL_ROW_MARKER)} ${theme.fg("toolTitle", theme.bold(label))}`;
|
|
279
281
|
if (compactSubject) text += ` ${theme.fg("accent", compactSubject)}`;
|
|
280
282
|
if (compactMeta) text += theme.fg("muted", ` · ${compactMeta}`);
|
|
281
283
|
return text;
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
previewLines,
|
|
48
48
|
renderRichDiffPreview,
|
|
49
49
|
renderToolSummary,
|
|
50
|
+
TOOL_ROW_MARKER,
|
|
50
51
|
shortenCommand,
|
|
51
52
|
shortenPath,
|
|
52
53
|
} from "./format";
|
|
@@ -252,7 +253,7 @@ type PatchableToolExecutionComponent = {
|
|
|
252
253
|
type ExternalToolFrameKind = "serena" | "gitnexus" | "structured" | "process" | "external";
|
|
253
254
|
|
|
254
255
|
const PATCHED_EXTERNAL_TOOL_FRAME = "__xtrmUiExternalToolFrame";
|
|
255
|
-
const EXTERNAL_TOOL_FRAME_PATCH_VERSION =
|
|
256
|
+
const EXTERNAL_TOOL_FRAME_PATCH_VERSION = 12;
|
|
256
257
|
const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
257
258
|
|
|
258
259
|
function stripAnsi(text: string): string {
|
|
@@ -291,21 +292,21 @@ function getToolArgs(component: PatchableToolExecutionComponent): Record<string,
|
|
|
291
292
|
function summarizeExternalToolPending(toolName: string | undefined, input: Record<string, unknown>): string {
|
|
292
293
|
const name = toolName ?? "tool";
|
|
293
294
|
if (name === "structured_return") {
|
|
294
|
-
return
|
|
295
|
+
return `${TOOL_ROW_MARKER} structured_return ${shortenCommand(String(input.command ?? "running"), 38)}`;
|
|
295
296
|
}
|
|
296
297
|
if (name === "process") {
|
|
297
|
-
return
|
|
298
|
+
return `${TOOL_ROW_MARKER} process ${String(input.action ?? "running")}`;
|
|
298
299
|
}
|
|
299
300
|
if (name.startsWith("gitnexus_")) {
|
|
300
301
|
const subject = summarizeSerenaSubject(name, input) ?? summarizeToolSubject(name, input);
|
|
301
|
-
return
|
|
302
|
+
return `${TOOL_ROW_MARKER} ${normalizeToolLabel(name)}${subject ? ` ${subject}` : ""}`;
|
|
302
303
|
}
|
|
303
304
|
if (SERENA_COMPACT_TOOLS.has(name)) {
|
|
304
305
|
const subject = summarizeSerenaSubject(name, input);
|
|
305
|
-
return
|
|
306
|
+
return `${TOOL_ROW_MARKER} serena ${name}${subject ? ` ${subject}` : ""}`;
|
|
306
307
|
}
|
|
307
308
|
const subject = summarizeToolSubject(name, input) ?? summarizeSerenaSubject(name, input);
|
|
308
|
-
return
|
|
309
|
+
return `${TOOL_ROW_MARKER} ${normalizeToolLabel(name)}${subject ? ` ${subject}` : ""}`;
|
|
309
310
|
}
|
|
310
311
|
|
|
311
312
|
function extractResultTextLines(component: PatchableToolExecutionComponent): string[] | undefined {
|
|
@@ -339,7 +340,7 @@ function externalToolBadgeColor(kind: ExternalToolFrameKind, text: string): stri
|
|
|
339
340
|
}
|
|
340
341
|
|
|
341
342
|
function highlightExternalToolBadge(kind: ExternalToolFrameKind, line: string): string {
|
|
342
|
-
const match = line.match(/^(
|
|
343
|
+
const match = line.match(/^([•›]\s+)(\S+)/u);
|
|
343
344
|
if (!match?.[1] || !match[2]) return line;
|
|
344
345
|
return match[1] + externalToolBadgeColor(kind, match[2]) + line.slice(match[1].length + match[2].length);
|
|
345
346
|
}
|
|
@@ -1140,28 +1141,28 @@ function summarizeSerenaToolResult(
|
|
|
1140
1141
|
case "jet_brains_find_symbol":
|
|
1141
1142
|
case "jet_brains_find_referencing_symbols": {
|
|
1142
1143
|
const count = countJsonItems(payload) ?? (text.match(/"name_path"\s*:/g)?.length ?? 0);
|
|
1143
|
-
return
|
|
1144
|
+
return `${TOOL_ROW_MARKER} serena ${toolName} ${subject ?? "symbol"}${meta(formatLineLabel(count, "result"), duration)}`;
|
|
1144
1145
|
}
|
|
1145
1146
|
case "get_symbols_overview":
|
|
1146
1147
|
case "jet_brains_get_symbols_overview":
|
|
1147
1148
|
case "jet_brains_type_hierarchy": {
|
|
1148
1149
|
const count = Math.max(countOverviewSymbols(payload), text.match(/"name_path"\s*:/g)?.length ?? 0);
|
|
1149
|
-
return
|
|
1150
|
+
return `${TOOL_ROW_MARKER} serena ${toolName} ${subject ?? "file"}${meta(formatLineLabel(count, "symbol"), duration)}`;
|
|
1150
1151
|
}
|
|
1151
1152
|
case "search_for_pattern": {
|
|
1152
1153
|
const count = countSearchMatches(payload) ?? (text.match(/^\s*>\s*\d+:/gm)?.length ?? 0);
|
|
1153
|
-
return
|
|
1154
|
+
return `${TOOL_ROW_MARKER} serena search ${subject ?? "pattern"}${meta(formatLineLabel(count, "match"), duration)}`;
|
|
1154
1155
|
}
|
|
1155
1156
|
case "read_file": {
|
|
1156
|
-
return
|
|
1157
|
+
return `${TOOL_ROW_MARKER} serena read ${subject ?? "file"}${meta(formatLineLabel(countLines(text), "line"), duration)}`;
|
|
1157
1158
|
}
|
|
1158
1159
|
case "list_dir": {
|
|
1159
1160
|
const count = countJsonItems(payload) ?? countLines(text);
|
|
1160
|
-
return
|
|
1161
|
+
return `${TOOL_ROW_MARKER} serena list_dir ${subject ?? "."}${meta(formatLineLabel(count, "entry"), duration)}`;
|
|
1161
1162
|
}
|
|
1162
1163
|
case "find_file": {
|
|
1163
1164
|
const count = countJsonItems(payload) ?? countLines(text);
|
|
1164
|
-
return
|
|
1165
|
+
return `${TOOL_ROW_MARKER} serena find_file ${String(input.file_mask ?? "")}${meta(formatLineLabel(count, "match"), duration)}`;
|
|
1165
1166
|
}
|
|
1166
1167
|
case "replace_symbol_body":
|
|
1167
1168
|
case "insert_after_symbol":
|
|
@@ -1182,14 +1183,14 @@ function summarizeSerenaToolResult(
|
|
|
1182
1183
|
case "restart_language_server":
|
|
1183
1184
|
case "onboarding":
|
|
1184
1185
|
case "serena_mcp_reset":
|
|
1185
|
-
return
|
|
1186
|
+
return `${TOOL_ROW_MARKER} serena ${toolName}${subject ? ` ${subject}` : ""}${meta(duration)}`;
|
|
1186
1187
|
case "execute_shell_command": {
|
|
1187
1188
|
const count = countLines(text);
|
|
1188
|
-
return
|
|
1189
|
+
return `${TOOL_ROW_MARKER} serena shell ${subject ?? "command"}${meta(formatLineLabel(count, "line"), duration)}`;
|
|
1189
1190
|
}
|
|
1190
1191
|
default: {
|
|
1191
1192
|
const count = countJsonItems(payload) ?? countLines(text);
|
|
1192
|
-
return
|
|
1193
|
+
return `${TOOL_ROW_MARKER} serena ${toolName}${subject ? ` ${subject}` : ""}${meta(formatLineLabel(count, "item"), duration)}`;
|
|
1193
1194
|
}
|
|
1194
1195
|
}
|
|
1195
1196
|
}
|
|
@@ -1257,7 +1258,7 @@ function summarizeGenericToolResult(
|
|
|
1257
1258
|
const label = formatLineLabel(count, "line");
|
|
1258
1259
|
const joined = joinMeta([label, duration]);
|
|
1259
1260
|
const normalized = normalizeToolLabel(toolName);
|
|
1260
|
-
return
|
|
1261
|
+
return `${TOOL_ROW_MARKER} ${normalized}${subject ? ` ${subject}` : ""}${joined ? ` · ${joined}` : ""}`;
|
|
1261
1262
|
}
|
|
1262
1263
|
|
|
1263
1264
|
function summarizeStructuredReturnToolResult(
|
|
@@ -1275,7 +1276,7 @@ function summarizeStructuredReturnToolResult(
|
|
|
1275
1276
|
const exitCode = typeof record?.exitCode === "number" ? `exit ${record.exitCode}` : undefined;
|
|
1276
1277
|
const duration = formatDuration(durationMs);
|
|
1277
1278
|
const meta = joinMeta([summary ? shortenCommand(summary, 72) : undefined, parser, exitCode, duration]);
|
|
1278
|
-
return
|
|
1279
|
+
return `${TOOL_ROW_MARKER} structured_return ${command}${meta ? ` · ${meta}` : ""}`;
|
|
1279
1280
|
}
|
|
1280
1281
|
|
|
1281
1282
|
function summarizeProcessToolResult(
|
|
@@ -1297,7 +1298,7 @@ function summarizeProcessToolResult(
|
|
|
1297
1298
|
const name = String(proc?.name ?? input.name ?? "process");
|
|
1298
1299
|
const id = proc?.id ? String(proc.id) : undefined;
|
|
1299
1300
|
const pid = proc?.pid != null ? `pid ${String(proc.pid)}` : undefined;
|
|
1300
|
-
return
|
|
1301
|
+
return `${TOOL_ROW_MARKER} process start "${name}"${meta(id, pid)}`;
|
|
1301
1302
|
}
|
|
1302
1303
|
|
|
1303
1304
|
if (action === "list") {
|
|
@@ -1306,25 +1307,25 @@ function summarizeProcessToolResult(
|
|
|
1306
1307
|
const proc = asRecord(item);
|
|
1307
1308
|
return proc?.status === "running" || proc?.status === "terminating";
|
|
1308
1309
|
}).length;
|
|
1309
|
-
return
|
|
1310
|
+
return `${TOOL_ROW_MARKER} process list${meta(`${processes.length} ${processes.length === 1 ? "process" : "processes"}`, `${running} running`)}`;
|
|
1310
1311
|
}
|
|
1311
1312
|
|
|
1312
1313
|
if (action === "output") {
|
|
1313
1314
|
const output = asRecord(record?.output);
|
|
1314
1315
|
const stdout = Array.isArray(output?.stdout) ? output.stdout.length : undefined;
|
|
1315
1316
|
const stderr = Array.isArray(output?.stderr) ? output.stderr.length : undefined;
|
|
1316
|
-
return
|
|
1317
|
+
return `${TOOL_ROW_MARKER} process output ${String(input.id ?? "process")}${meta(
|
|
1317
1318
|
stdout != null ? `${stdout} stdout` : undefined,
|
|
1318
1319
|
stderr != null ? `${stderr} stderr` : undefined,
|
|
1319
1320
|
)}`;
|
|
1320
1321
|
}
|
|
1321
1322
|
|
|
1322
1323
|
if (action === "logs") {
|
|
1323
|
-
return
|
|
1324
|
+
return `${TOOL_ROW_MARKER} process logs ${String(input.id ?? "process")}${meta("log paths")}`;
|
|
1324
1325
|
}
|
|
1325
1326
|
|
|
1326
1327
|
const message = typeof record?.message === "string" ? record.message : text.split("\n")[0];
|
|
1327
|
-
return
|
|
1328
|
+
return `${TOOL_ROW_MARKER} process ${action}${message ? ` · ${shortenCommand(message, 38)}` : ""}${duration ? ` · ${duration}` : ""}`;
|
|
1328
1329
|
}
|
|
1329
1330
|
|
|
1330
1331
|
function summarizeExternalToolResult(
|
|
@@ -1452,13 +1453,13 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
|
|
|
1452
1453
|
const meta = getXtrmMeta<BashToolDetails, Record<string, unknown>>(details);
|
|
1453
1454
|
const command = shortenCommand(String(meta?.args.command ?? ""), 38);
|
|
1454
1455
|
if (isPartial) {
|
|
1455
|
-
return toolRowText(theme, `${theme.fg("accent",
|
|
1456
|
+
return toolRowText(theme, `${theme.fg("accent", TOOL_ROW_MARKER)} ${theme.fg("toolTitle", "Running ")}${theme.fg("accent", command)}${theme.fg("toolTitle", " in bash")}`);
|
|
1456
1457
|
}
|
|
1457
1458
|
const output = getTextContent(result as any);
|
|
1458
1459
|
const outputLines = cleanOutputLines(output);
|
|
1459
1460
|
const exitMatch = output.match(/exit code:\s*(-?\d+)/i);
|
|
1460
1461
|
const exitCode = exitMatch ? Number.parseInt(exitMatch[1] ?? "0", 10) : 0;
|
|
1461
|
-
const bullet = exitCode === 0 ? theme.fg("success",
|
|
1462
|
+
const bullet = exitCode === 0 ? theme.fg("success", TOOL_ROW_MARKER) : theme.fg("error", TOOL_ROW_MARKER);
|
|
1462
1463
|
const summary = joinMeta([formatLineLabel(outputLines.length, "line"), formatDuration(meta?.durationMs), details.truncation?.truncated ? "truncated" : undefined]);
|
|
1463
1464
|
let text = `${bullet} ${theme.fg("toolTitle", "Ran ")}${theme.fg("accent", command)}`;
|
|
1464
1465
|
if (summary) text += theme.fg("dim", ` · ${summary}`);
|