@jaggerxtrm/pi-extensions 0.9.0 → 0.9.2

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.
@@ -371,6 +371,8 @@ export const __internals = {
371
371
  getProcessStartTime,
372
372
  findProcessesByPgid,
373
373
  getRepoRoot,
374
+ isInsideGitRepo,
375
+ resolveSessionCwd,
374
376
  readState,
375
377
  writeState,
376
378
  removeState,
@@ -379,9 +381,51 @@ export const __internals = {
379
381
  };
380
382
  export type { PoolState };
381
383
 
384
+ /** True when `cwd` is inside a git work tree (cheap synchronous probe). */
385
+ function isInsideGitRepo(cwd: string): boolean {
386
+ try {
387
+ return (
388
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
389
+ cwd,
390
+ encoding: "utf8",
391
+ stdio: ["ignore", "pipe", "ignore"],
392
+ }).trim() === "true"
393
+ );
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Resolve the cwd to bind the Serena daemon to.
401
+ *
402
+ * Why: pi's ctx.cwd is captured at session_start emission and can be a stale
403
+ * snapshot — most notably the parent repo when session_start fires before
404
+ * pi's own cwd has settled into the linked worktree the session actually runs
405
+ * in. The launcher (cli worktree-session) spawns pi with `cwd: worktreePath`,
406
+ * so `process.cwd()` is the live ground truth. The old `ctx?.cwd ??
407
+ * process.cwd()` form silently trusted a stale ctx.cwd because `??` only
408
+ * falls through on nullish — never when ctx.cwd is present-but-wrong. That
409
+ * produced a wrong hashToPort → daemon bound to the parent repo's --project →
410
+ * the next correct-cwd session found no listener on its expected port and
411
+ * spawned a SECOND daemon, leaving the first as an orphan (KAN-110-A).
412
+ *
413
+ * Resolution: when ctx.cwd and the live cwd disagree, trust the live cwd only
414
+ * if it is itself inside a git work tree — so we never bind the daemon to an
415
+ * unrelated process.cwd() (e.g. a test runner in /tmp). getRepoRoot then maps
416
+ * the worktree cwd to its true toplevel via `git rev-parse --show-toplevel`,
417
+ * which already returns the linked-worktree root, not the common dir.
418
+ */
419
+ function resolveSessionCwd(ctxCwd: string | undefined): string {
420
+ const procCwd = process.cwd();
421
+ if (!ctxCwd) return procCwd;
422
+ if (ctxCwd === procCwd) return ctxCwd;
423
+ return isInsideGitRepo(procCwd) ? procCwd : ctxCwd;
424
+ }
425
+
382
426
  export default function registerSerenaPool(pi: ExtensionAPI) {
383
427
  pi.on("session_start", async (_event: unknown, ctx: any) => {
384
- const cwd: string = ctx?.cwd ?? process.cwd();
428
+ const cwd: string = resolveSessionCwd(ctx?.cwd);
385
429
  let projectRoot: string;
386
430
  try {
387
431
  projectRoot = getRepoRoot(cwd);
@@ -8,21 +8,25 @@
8
8
  *
9
9
  * Requires: `uvx` on PATH (the real Serena spawn path is exercised).
10
10
  *
11
- * Five scenarios:
11
+ * Six scenarios:
12
12
  * 1. cold start — clean slate → spawn, port listens, state written
13
13
  * 2. warm reuse — second call → same pid, same state
14
14
  * 3. dead recovery — kill Serena → next call spawns fresh
15
15
  * 4. synthetic orphans — fake state pointing at a detached `sleep` group
16
16
  * with dead leader → cleanup kills group members
17
17
  * 5. concurrent spawn — 5 parallel calls → exactly one Serena alive
18
+ * 6. cwd-race — stale ctx.cwd (parent repo) vs live process.cwd()
19
+ * (linked worktree) → daemon binds to the worktree,
20
+ * no orphan against the parent. Uses real
21
+ * `git worktree add`.
18
22
  */
19
23
  import { spawn, spawnSync } from "node:child_process";
20
- import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
24
+ import { mkdtempSync, mkdirSync, rmSync, existsSync, readdirSync, realpathSync } from "node:fs";
21
25
  import { tmpdir } from "node:os";
22
26
  import { join } from "node:path";
23
27
  import { setTimeout as sleep } from "node:timers/promises";
24
28
 
25
- import {
29
+ import registerSerenaPool, {
26
30
  ensureSerenaForRoot,
27
31
  STATE_DIR,
28
32
  __internals as I,
@@ -83,6 +87,29 @@ async function withTestRoot(fn: (root: string, port: number) => Promise<void>):
83
87
  }
84
88
  }
85
89
 
90
+ function git(cwd: string, args: string[]): void {
91
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
92
+ if (r.status !== 0) {
93
+ throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${(r.stderr || "").trim()}`);
94
+ }
95
+ }
96
+
97
+ type SessionStartCtx = { cwd?: string };
98
+ type SessionStartHandler = (event: unknown, ctx: SessionStartCtx) => Promise<void>;
99
+
100
+ /** Register the extension against a fake pi and capture its session_start handler. */
101
+ function captureSessionStartHandler(): SessionStartHandler {
102
+ let captured: SessionStartHandler | null = null;
103
+ const fakePi = {
104
+ on(event: string, handler: SessionStartHandler) {
105
+ if (event === "session_start") captured = handler;
106
+ },
107
+ };
108
+ registerSerenaPool(fakePi as never);
109
+ if (!captured) throw new Error("registerSerenaPool did not register a session_start handler");
110
+ return captured;
111
+ }
112
+
86
113
  async function test1_coldStart(): Promise<void> {
87
114
  header("1. cold start");
88
115
  await withTestRoot(async (root, expectedPort) => {
@@ -197,6 +224,77 @@ async function test5_concurrentSpawn(): Promise<void> {
197
224
  });
198
225
  }
199
226
 
227
+ async function test6_cwdRace(): Promise<void> {
228
+ header("6. cwd-race (stale ctx.cwd resolves to live worktree)");
229
+ // Reproduce KAN-110-A: registerSerenaPool fires session_start with a ctx.cwd
230
+ // captured stale as the parent repo, while pi's LIVE process.cwd() is the
231
+ // linked worktree the session is actually running in. Uses a REAL
232
+ // `git worktree add` (not a bare mkdtemp) so git's worktree semantics are
233
+ // exercised end-to-end.
234
+ const tmpBase = mkdtempSync(join(tmpdir(), "serena-pool-race-"));
235
+ const parent = join(tmpBase, "parent");
236
+ const worktreeAbs = join(tmpBase, "wt");
237
+ mkdirSync(parent, { recursive: true });
238
+
239
+ const spawnedPorts: number[] = [];
240
+ const prevCwd = process.cwd();
241
+ const prevEnvPort = process.env.SERENA_MCP_PORT;
242
+ try {
243
+ cleanState();
244
+ git(parent, ["init", "-q"]);
245
+ git(parent, ["config", "user.email", "serena-pool-e2e@xtrm.local"]);
246
+ git(parent, ["config", "user.name", "serena-pool e2e"]);
247
+ git(parent, ["commit", "--allow-empty", "-q", "-m", "init"]);
248
+ // REAL linked worktree — git creates worktreeAbs.
249
+ git(parent, ["worktree", "add", "-q", worktreeAbs]);
250
+
251
+ const parentReal = realpathSync(parent);
252
+ const worktreeReal = realpathSync(worktreeAbs);
253
+ const expectedPort = I.hashToPort(worktreeReal);
254
+ const parentPort = I.hashToPort(parentReal);
255
+ spawnedPorts.push(expectedPort, parentPort);
256
+ assert(
257
+ expectedPort !== parentPort,
258
+ `worktree port (${expectedPort}) differs from parent port (${parentPort})`,
259
+ );
260
+
261
+ // Simulate the race: pi's LIVE cwd is the worktree (launcher guarantee),
262
+ // but ctx.cwd was captured stale as the parent repo.
263
+ process.chdir(worktreeReal);
264
+ const handler = captureSessionStartHandler();
265
+ await handler({}, { cwd: parentReal });
266
+
267
+ const state = I.readState(expectedPort);
268
+ assert(state != null, "state written at the worktree's port (not the parent's)");
269
+ assert(
270
+ state != null && state.projectRoot === worktreeReal,
271
+ `daemon --project === worktree root (got ${state?.projectRoot})`,
272
+ );
273
+ assert(
274
+ state != null && state.projectRoot !== parentReal,
275
+ "daemon --project is NOT the stale parent repo",
276
+ );
277
+ assert(state != null && I.isPidAlive(state.pid), "spawned Serena pid alive");
278
+
279
+ // No orphan daemon bound to the stale parent's port.
280
+ const orphan = I.readState(parentPort);
281
+ assert(orphan == null, "no daemon state written against the stale parent port");
282
+
283
+ // Env wiring reflects the worktree port.
284
+ assert(
285
+ process.env.SERENA_MCP_PORT === String(expectedPort),
286
+ `SERENA_MCP_PORT wired to worktree port ${expectedPort}`,
287
+ );
288
+ } finally {
289
+ for (const p of spawnedPorts) killSerenaForPort(p);
290
+ try { process.chdir(prevCwd); } catch { /* ignore */ }
291
+ try { git(parent, ["worktree", "remove", "--force", worktreeAbs]); } catch { /* parent may be gone */ }
292
+ rmSync(tmpBase, { recursive: true, force: true });
293
+ if (prevEnvPort === undefined) delete process.env.SERENA_MCP_PORT;
294
+ else process.env.SERENA_MCP_PORT = prevEnvPort;
295
+ }
296
+ }
297
+
200
298
  async function main(): Promise<void> {
201
299
  console.log("serena-pool e2e driver\n");
202
300
  if (!preflight()) process.exit(2);
@@ -207,6 +305,7 @@ async function main(): Promise<void> {
207
305
  test3_deadDaemonRecovery,
208
306
  test4_syntheticOrphanCleanup,
209
307
  test5_concurrentSpawn,
308
+ test6_cwdRace,
210
309
  ];
211
310
 
212
311
  for (const t of tests) {
@@ -0,0 +1,422 @@
1
+ /**
2
+ * xtprompt - context aware prompt improver.
3
+ *
4
+ * Global Pi extension. Shortcut: alt+m (alt+p intentionally avoided to not
5
+ * collide with pi-promptsmith if both are installed). Command: /msmith
6
+ *
7
+ * Rewrites the current editor draft via a standalone model call (the active
8
+ * model), using anthropic + xtrm/planning-aware intent templates + hard style rules, then writes
9
+ * the improved prompt back into the editor. The main agent turn never runs
10
+ * during the rewrite.
11
+ */
12
+ import { complete } from "@earendil-works/pi-ai";
13
+ import type {
14
+ Api,
15
+ AssistantMessage,
16
+ Context,
17
+ Model,
18
+ UserMessage,
19
+ } from "@earendil-works/pi-ai";
20
+ import {
21
+ BorderedLoader,
22
+ type ExtensionAPI,
23
+ type ExtensionContext,
24
+ } from "@earendil-works/pi-coding-agent";
25
+
26
+ const EXTENSION = "mercury-smith";
27
+ const COMMAND = "msmith";
28
+ const DEFAULT_SHORTCUT = "alt+m";
29
+
30
+ const SENTINEL_OPEN = "<mercury-smith>";
31
+ const SENTINEL_CLOSE = "</mercury-smith>";
32
+
33
+ const ENHANCER_MAX_OUTPUT_TOKENS = 1600;
34
+ const DEFAULT_TIMEOUT_MS = 45_000;
35
+
36
+ type Intent =
37
+ | "pane-dispatch"
38
+ | "specialist"
39
+ | "bead"
40
+ | "scope-plan"
41
+ | "debug"
42
+ | "generic";
43
+
44
+ interface SmithState {
45
+ enabled: boolean;
46
+ }
47
+
48
+ function createState(): SmithState {
49
+ return { enabled: true };
50
+ }
51
+
52
+ export default function mercurySmith(pi: ExtensionAPI): void {
53
+ const state = createState();
54
+
55
+ const enhance = async (ctx: ExtensionContext): Promise<void> => {
56
+ if (!state.enabled) {
57
+ ctx.ui.notify(`${EXTENSION} is disabled. Run /${COMMAND} on`, "info");
58
+ return;
59
+ }
60
+ if (!ctx.hasUI) {
61
+ ctx.ui.notify(`${EXTENSION} needs the interactive TUI.`, "error");
62
+ return;
63
+ }
64
+ const draft = ctx.ui.getEditorText();
65
+ if (!draft.trim()) {
66
+ ctx.ui.notify(`${EXTENSION}: editor is empty — nothing to rewrite.`, "info");
67
+ return;
68
+ }
69
+ const model = ctx.model;
70
+ if (!model) {
71
+ ctx.ui.notify(`${EXTENSION}: no active model selected.`, "error");
72
+ return;
73
+ }
74
+
75
+ const intent = detectIntent(draft);
76
+ const envContext = await gatherContext(pi).catch(() => "") ?? "";
77
+ const systemPrompt = buildSystemPrompt(intent, envContext);
78
+
79
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
80
+ if (!auth.ok) {
81
+ ctx.ui.notify(`${EXTENSION}: cannot resolve API key — ${auth.error}`, "error");
82
+ return;
83
+ }
84
+
85
+ const userMessage: UserMessage = {
86
+ role: "user",
87
+ content: draft,
88
+ timestamp: Date.now(),
89
+ };
90
+ const request: Context = {
91
+ systemPrompt,
92
+ messages: [userMessage],
93
+ };
94
+
95
+ const outcome = await runWithLoader(
96
+ ctx,
97
+ `${EXTENSION} rewriting (${intent})…`,
98
+ async (signal) => {
99
+ const primary = await callModel(model, request, auth, signal);
100
+ if (primary === null) return null;
101
+ const text = extractText(primary);
102
+ const parsed = parseSentinel(text);
103
+ if (parsed !== undefined) return parsed;
104
+ // retry once with a harder format reminder
105
+ const retried = await callModel(model, retryRequest(request), auth, signal);
106
+ if (retried === null) return null;
107
+ const parsed2 = parseSentinel(extractText(retried));
108
+ if (parsed2 !== undefined) return parsed2;
109
+ throw new Error(
110
+ `${EXTENSION}: model did not return the ${SENTINEL_OPEN} block after a retry. Leaving editor unchanged.`,
111
+ );
112
+ },
113
+ ).catch((err: unknown) => {
114
+ ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
115
+ return null;
116
+ });
117
+
118
+ if (outcome === null) return;
119
+ ctx.ui.setEditorText(outcome);
120
+ ctx.ui.notify(`${EXTENSION}: enhanced (${intent}).`, "info");
121
+ };
122
+
123
+ pi.registerShortcut(DEFAULT_SHORTCUT, {
124
+ description: `Rewrite the current editor draft (${EXTENSION})`,
125
+ handler: enhance,
126
+ });
127
+
128
+ pi.registerCommand(COMMAND, {
129
+ description: `${EXTENSION}: rewrite the current editor prompt`,
130
+ handler: async (args, ctx) => {
131
+ const arg = (args ?? "").trim().toLowerCase();
132
+ if (arg === "on") {
133
+ state.enabled = true;
134
+ ctx.ui.notify(`${EXTENSION} enabled.`, "info");
135
+ return;
136
+ }
137
+ if (arg === "off") {
138
+ state.enabled = false;
139
+ ctx.ui.notify(`${EXTENSION} disabled.`, "info");
140
+ return;
141
+ }
142
+ if (arg === "status") {
143
+ ctx.ui.notify(
144
+ `${EXTENSION}: ${state.enabled ? "enabled" : "disabled"} · shortcut ${DEFAULT_SHORTCUT} · /${COMMAND}`,
145
+ "info",
146
+ );
147
+ return;
148
+ }
149
+ await enhance(ctx);
150
+ },
151
+ });
152
+ }
153
+
154
+ /* ------------------------------------------------------------------ intent */
155
+
156
+ interface IntentRule {
157
+ intent: Intent;
158
+ patterns: RegExp[];
159
+ }
160
+
161
+ const INTENT_RULES: IntentRule[] = [
162
+ {
163
+ intent: "pane-dispatch",
164
+ patterns: [
165
+ /\bmultiplex(?:ing)?\b/,
166
+ /\bpane\b/,
167
+ /\bdispatch(?:ed)?\b/,
168
+ /\borchestrat(?:e|or|ion)\b/,
169
+ /\bsubordinate\b/,
170
+ /\bjudge\b/,
171
+ /\bdeploy monitor(?:ing)?\b/,
172
+ /\btmux\b/,
173
+ /\bsend .*to (?:pane|session|agent)\b/i,
174
+ ],
175
+ },
176
+ {
177
+ intent: "specialist",
178
+ patterns: [
179
+ /\bspecialist\b/,
180
+ /\bexecutor\b/,
181
+ /\breviewer\b/,
182
+ /\bexplorer\b/,
183
+ /\bdebugger\b/,
184
+ /\bsp (?:run|script|serve|node)\b/,
185
+ /\busing-specialists\b/,
186
+ /\bquant-methodologist\b/,
187
+ /\bquant-researcher\b/,
188
+ /\bspecialist chain\b/i,
189
+ ],
190
+ },
191
+ {
192
+ intent: "bead",
193
+ patterns: [
194
+ /\bbead\b/i,
195
+ /\bbd (?:create|update|close|show|dep)\b/,
196
+ /\b--parent\b/,
197
+ /\b--claim\b/,
198
+ /\bepic\b/i,
199
+ /\bacceptance criteria\b/i,
200
+ /\b7-section\b/i,
201
+ /\bPROBLEM\b/,
202
+ /\bSUCCESS\b/,
203
+ /\bNON_GOALS\b/,
204
+ /\bVALIDATION\b/,
205
+ ],
206
+ },
207
+ {
208
+ intent: "scope-plan",
209
+ patterns: [
210
+ /\bplanning\b/,
211
+ /\bscope(?: out| this)?\b/i,
212
+ /\barchitect(?:ure)?\b/,
213
+ /\bbreak (?:this )?down\b/i,
214
+ /\bdecompos(?:e|ition)\b/,
215
+ /\broadmap\b/,
216
+ /\bphase structure\b/i,
217
+ ],
218
+ },
219
+ {
220
+ intent: "debug",
221
+ patterns: [
222
+ /\bdebug\b/,
223
+ /\bfix\b/,
224
+ /\bbug\b/i,
225
+ /\bbroken\b/,
226
+ /\bfail(?:s|ed|ing)?\b/,
227
+ /\bcrash(?:es|ing)?\b/,
228
+ /\broot cause\b/i,
229
+ /\btraceback\b/,
230
+ /\bstack trace\b/i,
231
+ /\bregression\b/i,
232
+ ],
233
+ },
234
+ ];
235
+
236
+ function detectIntent(draft: string): Intent {
237
+ const text = draft.toLowerCase();
238
+ for (const rule of INTENT_RULES) {
239
+ if (rule.patterns.some((p) => p.test(text))) return rule.intent;
240
+ }
241
+ return "generic";
242
+ }
243
+
244
+ /* ----------------------------------------------------------- system prompt */
245
+
246
+ const MERCURY_RULES = [
247
+ "Target environment: Mercury market-data (futures analytics service stack).",
248
+ "1. mmd-api and mmd-mcp-server NEVER compute — they read pre-computed snapshots only; all analytics are produced by the feed services (mmd-snapshot-feed).",
249
+ "2. Always parameterize SQL (params={'symbol': symbol}); never f-string user input into queries.",
250
+ "3. Modern type hints (dict[str, Any], list[Foo]); imports use the new layout (from analytics... import, from api... import).",
251
+ "4. Every analytic family must be wrapped in compute(analytic_family=...); adding one without the wrap is contract drift.",
252
+ "5. Default to no comments; add one only when the WHY is non-obvious.",
253
+ "6. analytics/ is pure calc — no I/O, no datetime.now() at module scope, no DB access.",
254
+ "7. Use bd (beads) for ALL task tracking; create follow-ups with `bd create --parent <id>` so they never get lost.",
255
+ "8. Keep work tightly scoped to what is asked; flag unknowns explicitly rather than inventing requirements.",
256
+ ].join("\n");
257
+
258
+ const INTENT_TEMPLATES: Record<Intent, string> = {
259
+ "pane-dispatch":
260
+ "Rewrite this as a crisp, self-contained instruction for a subordinate agent running in one tmux pane of a multiplexed session. The instruction MUST include: (a) the pane's role and explicitly what it is NOT (helper vs orchestrator), (b) the exact first action, (c) observable success criteria, (d) the escalation/notify rule — when to surface back to the orchestrator and how, (e) the communication protocol in priority order: temp files / structured notes > beads updates > direct notify. Make it dependency-aware: state what this pane must wait for and what it produces for other panes.",
261
+ specialist:
262
+ "Rewrite this as a specialist dispatch contract suitable for `sp run` / using-specialists-v3. MUST include: the specialist role needed (executor / reviewer / explorer / debugger / quant-methodologist / quant-researcher), a precise task scope (files and symbols), explicit success criteria, the verification the specialist must run, and the OUTPUT the specialist hands back. Scope it to a single chain step.",
263
+ bead:
264
+ "Rewrite this as a well-formed beads issue description using the 7-section contract, with these exact section headers: ## PROBLEM, ## SUCCESS, ## SCOPE, ## NON_GOALS, ## CONSTRAINTS, ## VALIDATION, ## OUTPUT. Make every section concrete and observable. Include a logging/telemetry note in CONSTRAINTS or VALIDATION where relevant, and at least one smoke/integration check in VALIDATION. No placeholder text. Preserve every concrete detail (bead ids, file paths, symbol names) from the original.",
265
+ "scope-plan":
266
+ "Rewrite this as a structured plan: distinct phases (P0 scaffold → P1 core → P2 integration), the dependencies between them, what can run in parallel, the top risks, and a short blast-radius summary. Keep it scoped; flag unknowns explicitly.",
267
+ debug:
268
+ "Rewrite this as a focused debugging prompt: state the symptom precisely, the reproduction/observation steps already taken, the most likely suspect code paths, and what evidence to gather before changing code. Bias toward read-only investigation first (logs, Tempo traces, profiling) before any fix.",
269
+ generic:
270
+ "Rewrite this prompt to be clearer, more direct, and better structured for a coding agent: explicit goal, constraints, and success criteria up front. Preserve every concrete detail (paths, ids, numbers). Remove vagueness and filler. Do not add requirements not implied by the original.",
271
+ };
272
+
273
+ const OUTPUT_CONTRACT = [
274
+ `Return ONLY the rewritten prompt wrapped in exactly one sentinel block: ${SENTINEL_OPEN} ... ${SENTINEL_CLOSE}.`,
275
+ "No markdown code fences, no commentary, no text before or after the sentinel block.",
276
+ "Preserve EVERY concrete detail from the original: file paths, symbol names, bead ids, commands, numbers, constraints.",
277
+ "Do not invent new requirements or details that are not implied by the original draft.",
278
+ ].join("\n");
279
+
280
+ function buildSystemPrompt(intent: Intent, envContext: string): string {
281
+ return [
282
+ `You are ${EXTENSION}, an intent-aware prompt rewriter for the Mercury/xtrm coding environment.`,
283
+ "",
284
+ "Detected intent: " + intent,
285
+ INTENT_TEMPLATES[intent],
286
+ "",
287
+ "Hard style rules the rewritten prompt must respect where applicable:",
288
+ MERCURY_RULES,
289
+ envContext ? "\nActive context (advisory — use only if relevant):\n" + envContext : "",
290
+ "OUTPUT CONTRACT:",
291
+ OUTPUT_CONTRACT,
292
+ ].join("\n");
293
+ }
294
+
295
+ /* -------------------------------------------------------------- context */
296
+
297
+ async function gatherContext(pi: ExtensionAPI): Promise<string> {
298
+ const lines: string[] = [];
299
+ const branch = await readExec(pi, "git", ["rev-parse", "--abbrev-ref", "HEAD"]);
300
+ if (branch) lines.push("- git branch: " + branch);
301
+ const bead = await readExec(pi, "bd", ["list", "--status=in_progress"]);
302
+ if (bead) {
303
+ const first = bead.split("\n").find((l) => l.trim());
304
+ if (first) lines.push("- active bead: " + first.trim().slice(0, 120));
305
+ }
306
+ return lines.join("\n");
307
+ }
308
+
309
+ async function readExec(
310
+ pi: ExtensionAPI,
311
+ cmd: string,
312
+ args: string[],
313
+ ): Promise<string | undefined> {
314
+ try {
315
+ const r = await Promise.race([
316
+ pi.exec(cmd, args, { timeout: 4000 }),
317
+ new Promise<never>((_, rej) =>
318
+ setTimeout(() => rej(new Error("timeout")), 4500),
319
+ ),
320
+ ]);
321
+ const out = ((r.stdout ?? "") + (r.stderr ?? "")).trim();
322
+ return out || undefined;
323
+ } catch {
324
+ return undefined;
325
+ }
326
+ }
327
+
328
+ /* ----------------------------------------------------------- model calls */
329
+
330
+ interface AuthOk {
331
+ apiKey?: string;
332
+ headers?: Record<string, string>;
333
+ }
334
+
335
+ async function callModel(
336
+ model: Model<Api>,
337
+ request: Context,
338
+ auth: AuthOk,
339
+ signal: AbortSignal,
340
+ ): Promise<AssistantMessage | null> {
341
+ const timeoutCtl = new AbortController();
342
+ const t = setTimeout(() => timeoutCtl.abort(), DEFAULT_TIMEOUT_MS);
343
+ const sig = AbortSignal.any([signal, timeoutCtl.signal]);
344
+ try {
345
+ const res = await Promise.race<AssistantMessage | null>([
346
+ complete(model, request, {
347
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
348
+ ...(auth.headers ? { headers: auth.headers } : {}),
349
+ signal: sig,
350
+ maxTokens: Math.min(model.maxTokens, ENHANCER_MAX_OUTPUT_TOKENS),
351
+ }),
352
+ abortGuard(signal, null),
353
+ ]);
354
+ return res;
355
+ } finally {
356
+ clearTimeout(t);
357
+ }
358
+ }
359
+
360
+ function retryRequest(request: Context): Context {
361
+ const last = request.messages.at(-1);
362
+ const baseText = last && typeof last.content === "string" ? last.content : "";
363
+ const reminder = `\n\nIMPORTANT: reply with exactly one ${SENTINEL_OPEN} block and nothing else (no fences, no commentary).`;
364
+ const reminded: UserMessage = {
365
+ role: "user",
366
+ content: baseText + reminder,
367
+ timestamp: Date.now(),
368
+ };
369
+ return { ...request, messages: [...request.messages.slice(0, -1), reminded] };
370
+ }
371
+
372
+ function abortGuard<T>(signal: AbortSignal, value: T): Promise<T> {
373
+ if (signal.aborted) return Promise.resolve(value);
374
+ return new Promise<T>((resolve) =>
375
+ signal.addEventListener("abort", () => resolve(value), { once: true }),
376
+ );
377
+ }
378
+
379
+ function extractText(msg: AssistantMessage): string {
380
+ return msg.content
381
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
382
+ .map((p) => p.text)
383
+ .join("\n")
384
+ .trim();
385
+ }
386
+
387
+ function parseSentinel(text: string): string | undefined {
388
+ const start = text.indexOf(SENTINEL_OPEN);
389
+ const end = text.lastIndexOf(SENTINEL_CLOSE);
390
+ if (start === -1 || end === -1 || end <= start) return undefined;
391
+ const inner = text.slice(start + SENTINEL_OPEN.length, end).trim();
392
+ return inner || undefined;
393
+ }
394
+
395
+ /* --------------------------------------------------------------- loader */
396
+
397
+ async function runWithLoader(
398
+ ctx: ExtensionContext,
399
+ message: string,
400
+ task: (signal: AbortSignal) => Promise<string | null>,
401
+ ): Promise<string | null> {
402
+ let taskError: Error | undefined;
403
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
404
+ const loader = new BorderedLoader(tui, theme, message, { cancellable: true });
405
+ loader.onAbort = () => done(null);
406
+ void task(loader.signal)
407
+ .then((r) => {
408
+ if (!loader.signal.aborted) done(r);
409
+ })
410
+ .catch((err: unknown) => {
411
+ if (loader.signal.aborted) {
412
+ done(null);
413
+ return;
414
+ }
415
+ taskError = err instanceof Error ? err : new Error(`${EXTENSION} failed.`);
416
+ done(null);
417
+ });
418
+ return loader;
419
+ });
420
+ if (taskError) throw taskError;
421
+ return result;
422
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@xtrm/pi-xtprompt",
3
+ "version": "0.1.0",
4
+ "description": "xtrm Pi extension: context-aware prompt improver (mercury-smith)",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "keywords": [
10
+ "pi",
11
+ "extension",
12
+ "xtrm",
13
+ "prompt"
14
+ ],
15
+ "author": "xtrm",
16
+ "license": "MIT",
17
+ "pi": {
18
+ "extensions": [
19
+ "./index.ts"
20
+ ]
21
+ }
22
+ }
@@ -1363,6 +1363,37 @@ function withXtrmToolDetails(details: unknown, sourceText: string, toolName: str
1363
1363
 
1364
1364
  const XTRM_BUILTIN_TOOLS = new Set(["bash", "read", "edit", "write", "find", "grep", "ls"]);
1365
1365
 
1366
+ // Tools whose RESULT PAYLOAD the model needs verbatim in context. In pi, a tool_result
1367
+ // hook's return value REPLACES the model-facing content (not just the TUI render), so
1368
+ // compacting these would replace the model-facing payload with "· N lines" / "· N results"
1369
+ // for every pi surface that loads xtrm-ui — interactive sessions (where the human can
1370
+ // expand the TUI row but the model cannot) and any headless pi run that doesn't pass
1371
+ // --no-extensions. Specialists are unaffected: they run with --no-extensions and never
1372
+ // load xtrm-ui (selectively re-attach only quality-gates / service-skills / pi-gitnexus /
1373
+ // pi-serena-tools). Mutation/no-payload tools are still summarized below, preserving
1374
+ // most of the token savings.
1375
+ const PAYLOAD_TOOLS = new Set<string>([
1376
+ // Serena reads / inspection
1377
+ "read_file",
1378
+ "find_symbol",
1379
+ "find_referencing_symbols",
1380
+ "get_symbols_overview",
1381
+ "search_for_pattern",
1382
+ "find_file",
1383
+ "list_dir",
1384
+ "read_memory",
1385
+ // JetBrains backend equivalents
1386
+ "jet_brains_find_symbol",
1387
+ "jet_brains_find_referencing_symbols",
1388
+ "jet_brains_get_symbols_overview",
1389
+ "jet_brains_type_hierarchy",
1390
+ // GitNexus reads (same blindness risk)
1391
+ "gitnexus_query",
1392
+ "gitnexus_context",
1393
+ "gitnexus_impact",
1394
+ "gitnexus_detect_changes",
1395
+ ]);
1396
+
1366
1397
  function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): void {
1367
1398
  const activeToolCalls = new Map<string, string>();
1368
1399
 
@@ -1413,6 +1444,9 @@ function registerXtrmUiTools(pi: ExtensionAPI, getPrefs: () => XtrmUiPrefs): voi
1413
1444
  trackToolCallEnd(event.toolCallId);
1414
1445
  return undefined;
1415
1446
  }
1447
+ // Never compact read/inspect tools: the hook return replaces model-facing content,
1448
+ // so summarizing these would blind headless agents/specialists (no row to expand).
1449
+ if (PAYLOAD_TOOLS.has(event.toolName)) return undefined;
1416
1450
  if (!getPrefs().compactExternalToolResults) return undefined;
1417
1451
 
1418
1452
  const text = getTextContent({ content: event.content as Array<{ type: string; text?: string }> });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaggerxtrm/pi-extensions",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Unified Pi extension entrypoint for xtrm-managed extensions",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,3 @@
1
+ import registerExtension from "../../extensions/xtprompt/index.ts";
2
+
3
+ export default registerExtension;
package/src/registry.ts CHANGED
@@ -14,6 +14,7 @@ import qualityGatesExtension from "./extensions/quality-gates.ts";
14
14
  import serviceSkillsExtension from "./extensions/service-skills.ts";
15
15
  import sessionFlowExtension from "./extensions/session-flow.ts";
16
16
  import spTerminalOverlayExtension from "./extensions/sp-terminal-overlay.ts";
17
+ import xtprompt from "./extensions/xtprompt.ts";
17
18
  import xtrmLoaderExtension from "./extensions/xtrm-loader.ts";
18
19
  import xtrmUiExtension from "./extensions/xtrm-ui.ts";
19
20
 
@@ -37,6 +38,7 @@ export const managedPiExtensions: readonly ManagedPiExtension[] = [
37
38
  { id: "service-skills", register: serviceSkillsExtension },
38
39
  { id: "session-flow", register: sessionFlowExtension },
39
40
  { id: "sp-terminal-overlay", register: spTerminalOverlayExtension },
41
+ { id: "xtprompt", register: xtprompt },
40
42
  { id: "xtrm-loader", register: xtrmLoaderExtension },
41
43
  { id: "xtrm-ui", register: xtrmUiExtension },
42
44
  ];