xtrm-tools 0.5.26 → 0.5.28

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.
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * XTRM Custom Footer Extension
3
3
  *
4
- * Displays: XTRM brand, Model, Context%, CWD, Git branch, Beads chip
4
+ * Displays: XTRM brand, model/context, host, cwd, git branch/status, beads state.
5
5
  */
6
6
 
7
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
8
  import { truncateToWidth } from "@mariozechner/pi-tui";
9
+ import { basename, relative } from "node:path";
10
+ import { hostname } from "node:os";
9
11
 
10
12
  import { SubprocessRunner, EventAdapter } from "../core/lib";
11
13
 
@@ -13,25 +15,34 @@ export default function (pi: ExtensionAPI) {
13
15
  interface BeadState {
14
16
  claimId: string | null;
15
17
  shortId: string | null;
18
+ claimTitle: string | null;
16
19
  status: string | null;
17
20
  openCount: number;
18
21
  lastFetch: number;
19
22
  }
20
23
 
24
+ interface RuntimeState {
25
+ host: string;
26
+ displayDir: string;
27
+ branch: string | null;
28
+ gitStatus: string;
29
+ venv: string | null;
30
+ lastFetch: number;
31
+ }
32
+
21
33
  const STATUS_ICONS: Record<string, string> = {
22
34
  open: "○",
23
35
  in_progress: "◐",
24
36
  blocked: "●",
25
37
  closed: "✓",
26
38
  };
39
+
27
40
  // Chip background colours (raw ANSI — theme has no bg() API)
28
- const CHIP_BG_NEUTRAL = "\x1b[48;5;238m"; // dark gray
29
- const CHIP_BG_ACTIVE = "\x1b[48;5;39m"; // blue
30
- const CHIP_BG_BLOCKED = "\x1b[48;5;88m"; // red
31
- const CHIP_FG = "\x1b[38;5;15m"; // white
32
- const CHIP_RESET = "\x1b[0m";
33
- const chip = (text: string, bg = CHIP_BG_NEUTRAL): string =>
34
- `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
41
+ const CHIP_BG_NEUTRAL = "\x1b[48;5;238m";
42
+ const CHIP_BG_ACTIVE = "\x1b[48;5;39m";
43
+ const CHIP_BG_BLOCKED = "\x1b[48;5;88m";
44
+ const CHIP_FG = "\x1b[38;5;15m";
45
+ const CHIP_RESET = "\x1b[0m";
35
46
 
36
47
  const STATUS_BG: Record<string, string> = {
37
48
  open: CHIP_BG_NEUTRAL,
@@ -39,35 +50,144 @@ export default function (pi: ExtensionAPI) {
39
50
  blocked: CHIP_BG_BLOCKED,
40
51
  };
41
52
 
53
+ const chip = (text: string, bg = CHIP_BG_NEUTRAL): string => `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
54
+
42
55
  let capturedCtx: any = null;
43
- let sessionId: string = "";
44
- let beadState: BeadState = { claimId: null, shortId: null, status: null, openCount: 0, lastFetch: 0 };
45
- let refreshing = false;
56
+ let sessionId = "";
46
57
  let requestRender: (() => void) | null = null;
58
+
47
59
  const CACHE_TTL = 5000;
60
+ let refreshingBeads = false;
61
+ let refreshingRuntime = false;
62
+
63
+ let beadState: BeadState = {
64
+ claimId: null,
65
+ shortId: null,
66
+ claimTitle: null,
67
+ status: null,
68
+ openCount: 0,
69
+ lastFetch: 0,
70
+ };
71
+
72
+ let runtimeState: RuntimeState = {
73
+ host: hostname().split(".")[0] || "host",
74
+ displayDir: process.cwd(),
75
+ branch: null,
76
+ gitStatus: "",
77
+ venv: process.env.VIRTUAL_ENV ? basename(process.env.VIRTUAL_ENV) : null,
78
+ lastFetch: 0,
79
+ };
48
80
 
49
81
  const getCwd = () => capturedCtx?.cwd || process.cwd();
50
82
  const getShortId = (id: string) => id.split("-").pop() ?? id;
51
83
 
84
+ const parseGitFlags = (porcelain: string): string => {
85
+ let modified = false;
86
+ let staged = false;
87
+ let deleted = false;
88
+ for (const line of porcelain.split("\n").filter(Boolean)) {
89
+ if (/^ M|^AM|^MM/.test(line)) modified = true;
90
+ if (/^A |^M /.test(line)) staged = true;
91
+ if (/^ D|^D /.test(line)) deleted = true;
92
+ }
93
+ return `${modified ? "*" : ""}${staged ? "+" : ""}${deleted ? "-" : ""}`;
94
+ };
95
+
96
+ const refreshRuntimeState = async () => {
97
+ if (refreshingRuntime || Date.now() - runtimeState.lastFetch < CACHE_TTL) return;
98
+ refreshingRuntime = true;
99
+ const cwd = getCwd();
100
+ try {
101
+ const host = hostname().split(".")[0] || "host";
102
+ const venv = process.env.VIRTUAL_ENV ? basename(process.env.VIRTUAL_ENV) : null;
103
+ const rootResult = await SubprocessRunner.run("git", ["rev-parse", "--show-toplevel"], { cwd });
104
+ const repoRoot = rootResult.code === 0 ? rootResult.stdout.trim() : null;
105
+
106
+ const displayDir = repoRoot
107
+ ? (() => {
108
+ const relPath = relative(repoRoot, cwd) || ".";
109
+ return relPath === "." ? basename(repoRoot) : `${basename(repoRoot)}/${relPath}`;
110
+ })()
111
+ : (() => {
112
+ const parts = cwd.split("/");
113
+ return parts.length > 2 ? parts.slice(-2).join("/") : cwd;
114
+ })();
115
+
116
+ let branch: string | null = null;
117
+ let gitStatus = "";
118
+ if (repoRoot) {
119
+ const branchResult = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
120
+ branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
121
+
122
+ const porcelainResult = await SubprocessRunner.run("git", ["--no-optional-locks", "status", "--porcelain"], { cwd });
123
+ const baseFlags = porcelainResult.code === 0 ? parseGitFlags(porcelainResult.stdout) : "";
124
+
125
+ let upstreamFlags = "";
126
+ const abResult = await SubprocessRunner.run(
127
+ "git",
128
+ ["--no-optional-locks", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
129
+ { cwd },
130
+ );
131
+ if (abResult.code === 0) {
132
+ const [behindRaw, aheadRaw] = abResult.stdout.trim().split(/\s+/);
133
+ const behind = Number(behindRaw || 0);
134
+ const ahead = Number(aheadRaw || 0);
135
+ if (ahead > 0 && behind > 0) upstreamFlags = "↕";
136
+ else if (ahead > 0) upstreamFlags = "↑";
137
+ else if (behind > 0) upstreamFlags = "↓";
138
+ }
139
+
140
+ gitStatus = `${baseFlags}${upstreamFlags}`;
141
+ }
142
+
143
+ runtimeState = {
144
+ host,
145
+ displayDir,
146
+ branch,
147
+ gitStatus,
148
+ venv,
149
+ lastFetch: Date.now(),
150
+ };
151
+ requestRender?.();
152
+ } catch {
153
+ // Fail soft — keep last known runtime state.
154
+ } finally {
155
+ refreshingRuntime = false;
156
+ }
157
+ };
158
+
52
159
  const refreshBeadState = async () => {
53
- if (refreshing || Date.now() - beadState.lastFetch < CACHE_TTL) return;
160
+ if (refreshingBeads || Date.now() - beadState.lastFetch < CACHE_TTL) return;
54
161
  const cwd = getCwd();
55
- if (!EventAdapter.isBeadsProject(cwd)) return;
56
- if (!sessionId) return;
57
- refreshing = true;
162
+ if (!EventAdapter.isBeadsProject(cwd) || !sessionId) return;
163
+ refreshingBeads = true;
58
164
  try {
59
165
  const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
60
166
  const claimId = claimResult.code === 0 ? claimResult.stdout.trim() || null : null;
61
167
 
62
168
  let status: string | null = null;
169
+ let claimTitle: string | null = null;
63
170
  if (claimId) {
64
171
  const showResult = await SubprocessRunner.run("bd", ["show", claimId, "--json"], { cwd });
65
172
  if (showResult.code === 0) {
66
- try { status = JSON.parse(showResult.stdout)[0]?.status ?? null; } catch {}
173
+ try {
174
+ const issue = JSON.parse(showResult.stdout)?.[0];
175
+ status = issue?.status ?? null;
176
+ claimTitle = issue?.title ?? null;
177
+ } catch {
178
+ // keep nulls
179
+ }
67
180
  }
68
181
  if (status === "closed") {
69
182
  await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
70
- beadState = { claimId: null, shortId: null, status: null, openCount: beadState.openCount, lastFetch: Date.now() };
183
+ beadState = {
184
+ claimId: null,
185
+ shortId: null,
186
+ claimTitle: null,
187
+ status: null,
188
+ openCount: beadState.openCount,
189
+ lastFetch: Date.now(),
190
+ };
71
191
  requestRender?.();
72
192
  return;
73
193
  }
@@ -80,10 +200,20 @@ export default function (pi: ExtensionAPI) {
80
200
  if (m) openCount = parseInt(m[1], 10);
81
201
  }
82
202
 
83
- beadState = { claimId, shortId: claimId ? getShortId(claimId) : null, status, openCount, lastFetch: Date.now() };
203
+ beadState = {
204
+ claimId,
205
+ shortId: claimId ? getShortId(claimId) : null,
206
+ claimTitle,
207
+ status,
208
+ openCount,
209
+ lastFetch: Date.now(),
210
+ };
84
211
  requestRender?.();
85
- } catch {}
86
- finally { refreshing = false; }
212
+ } catch {
213
+ // Fail soft keep last known beads state.
214
+ } finally {
215
+ refreshingBeads = false;
216
+ }
87
217
  };
88
218
 
89
219
  const buildBeadChip = (): string => {
@@ -93,68 +223,134 @@ export default function (pi: ExtensionAPI) {
93
223
  const bg = STATUS_BG[status] ?? CHIP_BG_NEUTRAL;
94
224
  return chip(`bd:${shortId}${icon}`, bg);
95
225
  }
226
+ if (openCount > 0) return chip(`bd:${openCount}${STATUS_ICONS.open}`);
227
+ return "";
228
+ };
229
+
230
+ const buildIssueLine = (width: number, theme: any): string => {
231
+ const { shortId, claimTitle, openCount } = beadState;
232
+ if (shortId && claimTitle) {
233
+ const prefix = `◐ ${shortId} `;
234
+ const title = theme.fg("muted", claimTitle);
235
+ return truncateToWidth(`${prefix}${title}`, width);
236
+ }
96
237
  if (openCount > 0) {
97
- return chip(`bd:${openCount}${STATUS_ICONS.open}`);
238
+ return truncateToWidth(`○ ${openCount} open`, width);
98
239
  }
99
- return "";
240
+ return truncateToWidth("○ no open issues", width);
100
241
  };
101
242
 
102
- pi.on("session_start", async (_event, ctx) => {
103
- capturedCtx = ctx;
104
- // Get session ID from sessionManager/context (prefer UUID, consistent with hooks)
105
- sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
243
+ let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
106
244
 
245
+ const applyCustomFooter = (ctx: any) => {
246
+ capturedCtx = ctx;
107
247
  ctx.ui.setFooter((tui, theme, footerData) => {
108
248
  requestRender = () => tui.requestRender();
109
- const unsub = footerData.onBranchChange(() => tui.requestRender());
249
+ const unsub = footerData.onBranchChange(() => {
250
+ runtimeState.lastFetch = 0;
251
+ tui.requestRender();
252
+ });
110
253
 
111
254
  return {
112
- dispose() { unsub(); requestRender = null; },
255
+ dispose() {
256
+ unsub();
257
+ requestRender = null;
258
+ },
113
259
  invalidate() {},
114
260
  render(width: number): string[] {
261
+ refreshRuntimeState().catch(() => {});
115
262
  refreshBeadState().catch(() => {});
116
263
 
117
- const BOLD = "\x1b[1m", BOLD_OFF = "\x1b[22m";
264
+ const BOLD = "\x1b[1m";
265
+ const BOLD_OFF = "\x1b[22m";
118
266
  const brand = `${BOLD}${theme.fg("accent", "XTRM")}${BOLD_OFF}`;
119
267
 
120
268
  const usage = ctx.getContextUsage();
121
269
  const pct = usage?.percent ?? 0;
122
270
  const pctColor = pct > 75 ? "error" : pct > 50 ? "warning" : "success";
123
- const usageStr = theme.fg(pctColor, `${pct.toFixed(0)}%`);
124
-
125
- const parts = process.cwd().split("/");
126
- const short = parts.length > 2 ? parts.slice(-2).join("/") : process.cwd();
127
- const cwdStr = theme.fg("muted", `⌂ ${short}`);
128
-
129
- const branch = footerData.getGitBranch();
130
- const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
271
+ const usageStr = theme.fg(pctColor, `[${pct.toFixed(0)}%]`);
131
272
 
132
273
  const modelId = ctx.model?.id || "no-model";
133
- const modelChip = chip(modelId);
134
-
135
- const sep = " ";
274
+ const modelStr = `${modelId} ${usageStr}`;
136
275
 
137
- const brandModel = `${brand} ${modelChip}`;
276
+ const branchFromFooter = footerData.getGitBranch();
277
+ const branch = runtimeState.branch || branchFromFooter;
278
+ const branchWithStatus = branch
279
+ ? runtimeState.gitStatus
280
+ ? `${branch} (${runtimeState.gitStatus})`
281
+ : branch
282
+ : "";
283
+ const branchStr = branchWithStatus ? theme.fg("muted", branchWithStatus) : "";
284
+ const hostStr = theme.fg("muted", runtimeState.host);
285
+ const cwdStr = `${BOLD}${runtimeState.displayDir}${BOLD_OFF}`;
286
+ const venvStr = runtimeState.venv ? theme.fg("muted", `(${runtimeState.venv})`) : "";
138
287
  const beadChip = buildBeadChip();
139
- const leftParts = [brandModel, usageStr];
140
- if (beadChip) leftParts.push(beadChip);
141
- leftParts.push(cwdStr);
142
- if (branchStr) leftParts.push(branchStr);
143
288
 
144
- const left = leftParts.join(sep);
145
- return [truncateToWidth(left, width)];
289
+ const line1Parts = [brand, modelStr, hostStr, cwdStr];
290
+ if (branchStr) line1Parts.push(branchStr);
291
+ if (venvStr) line1Parts.push(venvStr);
292
+ if (beadChip) line1Parts.push(beadChip);
293
+
294
+ const line1 = truncateToWidth(line1Parts.join(" "), width);
295
+ const line2 = buildIssueLine(width, theme);
296
+ return [line1, line2];
146
297
  },
147
298
  };
148
299
  });
300
+ };
301
+
302
+ const scheduleFooterReapply = (ctx: any, delayMs = 40) => {
303
+ if (footerReapplyTimer) clearTimeout(footerReapplyTimer);
304
+ footerReapplyTimer = setTimeout(() => {
305
+ applyCustomFooter(ctx);
306
+ footerReapplyTimer = null;
307
+ }, delayMs);
308
+ };
309
+
310
+ pi.on("session_start", async (_event, ctx) => {
311
+ capturedCtx = ctx;
312
+ sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
313
+ runtimeState.lastFetch = 0;
314
+ beadState.lastFetch = 0;
315
+ applyCustomFooter(ctx);
316
+ scheduleFooterReapply(ctx);
317
+ });
318
+
319
+ pi.on("session_switch", async (_event, ctx) => {
320
+ runtimeState.lastFetch = 0;
321
+ scheduleFooterReapply(ctx);
322
+ });
323
+
324
+ pi.on("session_fork", async (_event, ctx) => {
325
+ runtimeState.lastFetch = 0;
326
+ scheduleFooterReapply(ctx);
149
327
  });
150
328
 
151
- // Bust the bead cache immediately after any bd write
329
+ pi.on("model_select", async (_event, ctx) => {
330
+ runtimeState.lastFetch = 0;
331
+ scheduleFooterReapply(ctx);
332
+ });
333
+
334
+ pi.on("session_shutdown", async () => {
335
+ if (footerReapplyTimer) {
336
+ clearTimeout(footerReapplyTimer);
337
+ footerReapplyTimer = null;
338
+ }
339
+ });
340
+
341
+ // Bust caches immediately after relevant writes
152
342
  pi.on("tool_result", async (event: any) => {
153
343
  const cmd = event?.input?.command;
154
- if (cmd && /\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
344
+ if (!cmd) return undefined;
345
+
346
+ if (/\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
155
347
  beadState.lastFetch = 0;
156
348
  setTimeout(() => refreshBeadState().catch(() => {}), 200);
157
349
  }
350
+ if (/\bgit\s+/.test(cmd)) {
351
+ runtimeState.lastFetch = 0;
352
+ setTimeout(() => refreshRuntimeState().catch(() => {}), 200);
353
+ }
158
354
  return undefined;
159
355
  });
160
356
  }
@@ -1,9 +1,19 @@
1
- import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
2
- import { SubprocessRunner, EventAdapter, Logger } from "../core/lib";
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { SubprocessRunner, EventAdapter } from "../core/lib";
3
3
  import * as path from "node:path";
4
4
  import * as fs from "node:fs";
5
5
 
6
- const logger = new Logger({ namespace: "quality-gates" });
6
+ function resolveQualityHook(cwd: string, ext: string): { runner: string; scriptPath: string } | null {
7
+ if ([".ts", ".tsx", ".js", ".jsx", ".cjs", ".mjs"].includes(ext)) {
8
+ const scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.cjs");
9
+ return { runner: "node", scriptPath };
10
+ }
11
+ if (ext === ".py") {
12
+ const scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.py");
13
+ return { runner: "python3", scriptPath };
14
+ }
15
+ return null;
16
+ }
7
17
 
8
18
  export default function (pi: ExtensionAPI) {
9
19
  pi.on("tool_result", async (event, ctx) => {
@@ -15,27 +25,17 @@ export default function (pi: ExtensionAPI) {
15
25
 
16
26
  const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
17
27
  const ext = path.extname(fullPath);
18
-
19
- let scriptPath: string | null = null;
20
- let runner: string = "node";
21
-
22
- if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
23
- scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.cjs");
24
- runner = "node";
25
- } else if (ext === ".py") {
26
- scriptPath = path.join(cwd, ".claude", "hooks", "quality-check.py");
27
- runner = "python3";
28
- }
29
-
30
- if (!scriptPath || !fs.existsSync(scriptPath)) return undefined;
28
+ const resolved = resolveQualityHook(cwd, ext);
29
+ if (!resolved) return undefined;
30
+ if (!fs.existsSync(resolved.scriptPath)) return undefined;
31
31
 
32
32
  const hookInput = JSON.stringify({
33
33
  tool_name: event.toolName,
34
34
  tool_input: event.input,
35
- cwd: cwd,
35
+ cwd,
36
36
  });
37
37
 
38
- const result = await SubprocessRunner.run(runner, [scriptPath], {
38
+ const result = await SubprocessRunner.run(resolved.runner, [resolved.scriptPath], {
39
39
  cwd,
40
40
  input: hookInput,
41
41
  env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
@@ -43,23 +43,22 @@ export default function (pi: ExtensionAPI) {
43
43
  });
44
44
 
45
45
  if (result.code === 0) {
46
- if (result.stderr && result.stderr.trim()) {
47
- const newContent = [...event.content];
48
- newContent.push({ type: "text", text: `\n\n**Quality Gate**: ${result.stderr.trim()}` });
49
- return { content: newContent };
50
- }
51
- return undefined;
46
+ const details = (result.stdout || result.stderr || "").trim();
47
+ if (!details) return undefined;
48
+ return {
49
+ content: [...event.content, { type: "text", text: `\n\n**Quality Gate**: ${details}` }],
50
+ };
52
51
  }
53
52
 
54
53
  if (result.code === 2) {
55
- const newContent = [...event.content];
56
- newContent.push({ type: "text", text: `\n\n**Quality Gate FAILED**:\n${result.stderr || result.stdout || "Unknown error"}` });
57
-
54
+ const details = (result.stderr || result.stdout || "Unknown error").trim();
58
55
  if (ctx.hasUI) {
59
56
  ctx.ui.notify(`Quality Gate failed for ${path.basename(fullPath)}`, "error");
60
57
  }
61
-
62
- return { isError: true, content: newContent };
58
+ return {
59
+ isError: true,
60
+ content: [...event.content, { type: "text", text: `\n\n**Quality Gate FAILED**:\n${details}` }],
61
+ };
63
62
  }
64
63
 
65
64
  return undefined;
@@ -14,8 +14,39 @@ function isWorktree(cwd: string): boolean {
14
14
  return cwd.includes("/.xtrm/worktrees/") || cwd.includes("/.claude/worktrees/");
15
15
  }
16
16
 
17
+ function getSessionId(ctx: any): string {
18
+ return ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? process.pid.toString();
19
+ }
20
+
21
+ async function getSessionClaim(cwd: string, sessionId: string): Promise<string | null> {
22
+ const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
23
+ if (claimResult.code !== 0) return null;
24
+ const claimId = claimResult.stdout.trim();
25
+ return claimId.length > 0 ? claimId : null;
26
+ }
27
+
28
+ async function isClaimStillInProgress(cwd: string, issueId: string): Promise<boolean> {
29
+ const showResult = await SubprocessRunner.run("bd", ["show", issueId, "--json"], { cwd });
30
+ if (showResult.code === 0 && showResult.stdout.trim()) {
31
+ try {
32
+ const parsed = JSON.parse(showResult.stdout);
33
+ const record = Array.isArray(parsed) ? parsed[0] : parsed;
34
+ if (record?.status) return record.status === "in_progress";
35
+ } catch {
36
+ // fall back to text parsing below
37
+ }
38
+ }
39
+
40
+ const listResult = await SubprocessRunner.run("bd", ["list", "--status=in_progress"], { cwd });
41
+ if (listResult.code !== 0) return false;
42
+ const issuePattern = new RegExp(`^\\s*[◐●]?\\s*${issueId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "m");
43
+ return issuePattern.test(listResult.stdout);
44
+ }
45
+
17
46
  export default function (pi: ExtensionAPI) {
18
47
  const getCwd = (ctx: any) => ctx.cwd || process.cwd();
48
+ let lastStopNoticeIssue: string | null = null;
49
+ let lastWorktreeReminderCwd: string | null = null;
19
50
 
20
51
  // Claim sync: notify when a bd update --claim command is run.
21
52
  pi.on("tool_result", async (event, ctx) => {
@@ -31,35 +62,33 @@ export default function (pi: ExtensionAPI) {
31
62
  return { content: [...event.content, { type: "text", text }] };
32
63
  });
33
64
 
34
- // Stop gate: block agent end if there is an in_progress claimed issue.
35
- // Also remind to run `xt end` when session ends inside a worktree.
65
+ // Stop gate: warn (non-blocking) if this session's claimed issue is still in progress.
66
+ // IMPORTANT: never call sendUserMessage() from agent_end, it always triggers a new turn.
36
67
  pi.on("agent_end", async (_event, ctx) => {
37
68
  const cwd = getCwd(ctx);
38
69
  if (!EventAdapter.isBeadsProject(cwd)) return undefined;
39
70
 
40
- const inProgressResult = await SubprocessRunner.run(
41
- "bd",
42
- ["list", "--status=in_progress"],
43
- { cwd },
44
- );
45
- if (inProgressResult.code === 0 && inProgressResult.stdout) {
46
- const output = inProgressResult.stdout;
47
- const m = output.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
48
- const inProgressCount = m ? parseInt(m[2], 10) : 0;
49
- if (inProgressCount > 0) {
50
- const idMatch = output.match(/^\s*([a-zA-Z0-9._-]+)\s+in_progress/m);
51
- const issueId = idMatch ? idMatch[1] : "<id>";
52
- pi.sendUserMessage(
53
- `Stop blocked: close your issue first: bd close ${issueId}`,
54
- );
71
+ const sessionId = getSessionId(ctx);
72
+ const claimId = await getSessionClaim(cwd, sessionId);
73
+
74
+ if (claimId) {
75
+ const inProgress = await isClaimStillInProgress(cwd, claimId);
76
+ if (inProgress) {
77
+ if (lastStopNoticeIssue !== claimId && ctx.hasUI) {
78
+ ctx.ui.notify(`Stop blocked: close your issue first: bd close ${claimId}`, "warning");
79
+ lastStopNoticeIssue = claimId;
80
+ }
55
81
  return undefined;
56
82
  }
83
+
84
+ if (lastStopNoticeIssue === claimId) {
85
+ lastStopNoticeIssue = null;
86
+ }
57
87
  }
58
88
 
59
- if (isWorktree(cwd)) {
60
- pi.sendUserMessage(
61
- "Run `xt end` to create a PR and clean up this worktree.",
62
- );
89
+ if (isWorktree(cwd) && ctx.hasUI && lastWorktreeReminderCwd !== cwd) {
90
+ ctx.ui.notify("Run `xt end` to create a PR and clean up this worktree.", "info");
91
+ lastWorktreeReminderCwd = cwd;
63
92
  }
64
93
 
65
94
  return undefined;