xtrm-tools 0.5.26 → 0.5.27

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/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "0.5.26",
3
+ "version": "0.5.27",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
package/config/hooks.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "hooks": {
3
3
  "SessionStart": [
4
+ {
5
+ "script": "using-xtrm-reminder.mjs"
6
+ },
4
7
  {
5
8
  "script": "beads-compact-restore.mjs",
6
9
  "timeout": 5000
7
10
  },
8
11
  {
9
- "script": "xtrm-session-logger.mjs",
10
- "timeout": 3000
11
- }
12
- ],
13
- "UserPromptSubmit": [
12
+ "script": "quality-check-env.mjs",
13
+ "timeout": 5000
14
+ },
14
15
  {
15
- "script": "branch-state.mjs",
16
+ "script": "xtrm-session-logger.mjs",
16
17
  "timeout": 3000
17
18
  }
18
19
  ],
@@ -40,17 +41,17 @@
40
41
  "timeout": 5000
41
42
  },
42
43
  {
43
- "matcher": "Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
44
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
44
45
  "script": "quality-check.cjs",
45
46
  "timeout": 30000
46
47
  },
47
48
  {
48
- "matcher": "Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
49
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
49
50
  "script": "quality-check.py",
50
51
  "timeout": 30000
51
52
  },
52
53
  {
53
- "matcher": "Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview|mcp__serena__search_for_pattern|mcp__serena__find_referencing_symbols|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__rename_symbol",
54
+ "matcher": "Bash|Grep|Read|Glob|mcp__serena__find_symbol|mcp__serena__get_symbols_overview|mcp__serena__search_for_pattern|mcp__serena__find_referencing_symbols|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__rename_symbol",
54
55
  "script": "gitnexus/gitnexus-hook.cjs",
55
56
  "timeout": 10000
56
57
  },
@@ -77,6 +78,6 @@
77
78
  ]
78
79
  },
79
80
  "statusLine": {
80
- "script": "statusline-starship.sh"
81
+ "script": "statusline.mjs"
81
82
  }
82
83
  }
@@ -1,8 +1,8 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
3
- import { SubprocessRunner, EventAdapter, Logger } from "../core/lib";
4
-
5
- const logger = new Logger({ namespace: "beads" });
3
+ import { existsSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { SubprocessRunner, EventAdapter } from "../core/lib";
6
6
 
7
7
  export default function (pi: ExtensionAPI) {
8
8
  const getCwd = (ctx: any) => ctx.cwd || process.cwd();
@@ -21,8 +21,51 @@ export default function (pi: ExtensionAPI) {
21
21
 
22
22
  const getSessionClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
23
23
  const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
24
- if (result.code === 0) return result.stdout.trim();
25
- return null;
24
+ if (result.code !== 0) return null;
25
+ const claim = result.stdout.trim();
26
+ return claim.length > 0 ? claim : null;
27
+ };
28
+
29
+ const clearClaimMarker = async (sessionId: string, cwd: string) => {
30
+ await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
31
+ };
32
+
33
+ const isIssueInProgress = async (issueId: string, cwd: string): Promise<boolean | null> => {
34
+ const result = await SubprocessRunner.run("bd", ["show", issueId, "--json"], { cwd });
35
+ if (result.code !== 0 || !result.stdout.trim()) return null;
36
+ try {
37
+ const parsed = JSON.parse(result.stdout);
38
+ const issue = Array.isArray(parsed) ? parsed[0] : parsed;
39
+ if (!issue?.status) return null;
40
+ return issue.status === "in_progress";
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const getActiveClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
47
+ const claim = await getSessionClaim(sessionId, cwd);
48
+ if (!claim) return null;
49
+
50
+ const inProgress = await isIssueInProgress(claim, cwd);
51
+ if (inProgress === false) {
52
+ await clearClaimMarker(sessionId, cwd);
53
+ return null;
54
+ }
55
+
56
+ return claim;
57
+ };
58
+
59
+ const getClosedThisSession = async (sessionId: string, cwd: string): Promise<string | null> => {
60
+ const result = await SubprocessRunner.run("bd", ["kv", "get", `closed-this-session:${sessionId}`], { cwd });
61
+ if (result.code !== 0) return null;
62
+ const issue = result.stdout.trim();
63
+ return issue.length > 0 ? issue : null;
64
+ };
65
+
66
+ const clearSessionMarkers = async (sessionId: string, cwd: string) => {
67
+ await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
68
+ await SubprocessRunner.run("bd", ["kv", "clear", `closed-this-session:${sessionId}`], { cwd });
26
69
  };
27
70
 
28
71
  const hasTrackableWork = async (cwd: string): Promise<boolean> => {
@@ -34,15 +77,6 @@ export default function (pi: ExtensionAPI) {
34
77
  return false;
35
78
  };
36
79
 
37
- const hasInProgressWork = async (cwd: string): Promise<boolean> => {
38
- const result = await SubprocessRunner.run("bd", ["list"], { cwd });
39
- if (result.code === 0 && result.stdout.includes("Total:")) {
40
- const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
41
- if (m) return parseInt(m[2], 10) > 0;
42
- }
43
- return false;
44
- };
45
-
46
80
  pi.on("session_start", async (_event, ctx) => {
47
81
  cachedSessionId = ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId ?? ctx?.session_id ?? cachedSessionId;
48
82
  return undefined;
@@ -54,33 +88,30 @@ export default function (pi: ExtensionAPI) {
54
88
  const sessionId = getSessionId(ctx);
55
89
 
56
90
  if (EventAdapter.isMutatingFileTool(event)) {
57
- const claim = await getSessionClaim(sessionId, cwd);
91
+ const claim = await getActiveClaim(sessionId, cwd);
58
92
  if (!claim) {
59
- const hasWork = await hasTrackableWork(cwd);
60
- if (hasWork) {
61
- if (ctx.hasUI) {
62
- ctx.ui.notify("Beads: Edit blocked. Claim an issue first.", "warning");
63
- }
64
- return {
65
- block: true,
66
- reason: `No active claim for session ${sessionId}.\n bd update <id> --claim\n`,
67
- };
68
- }
69
- }
93
+ const hasWork = await hasTrackableWork(cwd);
94
+ if (hasWork) {
95
+ if (ctx.hasUI) {
96
+ ctx.ui.notify("Beads: Edit blocked. Claim an issue first.", "warning");
97
+ }
98
+ return {
99
+ block: true,
100
+ reason: `No active claim for session ${sessionId}.\n bd update <id> --claim\n`,
101
+ };
102
+ }
103
+ }
70
104
  }
71
105
 
72
106
  if (isToolCallEventType("bash", event)) {
73
107
  const command = event.input.command;
74
108
  if (command && /\bgit\s+commit\b/.test(command)) {
75
- const claim = await getSessionClaim(sessionId, cwd);
109
+ const claim = await getActiveClaim(sessionId, cwd);
76
110
  if (claim) {
77
- const inProgress = await hasInProgressWork(cwd);
78
- if (inProgress) {
79
- return {
80
- block: true,
81
- reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n (Pi workflow) publish/merge are external steps; do not rely on xtrm finish.\n`,
82
- };
83
- }
111
+ return {
112
+ block: true,
113
+ reason: `Active claim [${claim}] — close it first.\n bd close ${claim}\n (Pi workflow) publish/merge are external steps; do not rely on xtrm finish.\n`,
114
+ };
84
115
  }
85
116
  }
86
117
  }
@@ -89,65 +120,61 @@ export default function (pi: ExtensionAPI) {
89
120
  });
90
121
 
91
122
  pi.on("tool_result", async (event, ctx) => {
92
- if (isBashToolResult(event)) {
93
- const command = event.input.command;
94
- const sessionId = getSessionId(ctx);
95
-
96
- // Auto-claim on bd update --claim regardless of exit code.
97
- // bd returns exit 1 with "already in_progress" when status unchanged — still a valid claim intent.
98
- if (command && /\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
99
- const issueMatch = command.match(/\bbd\s+update\s+(\S+)/);
100
- if (issueMatch) {
101
- const issueId = issueMatch[1];
102
- const cwd = getCwd(ctx);
103
- await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
104
- const claimNotice = `\n\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
105
- return { content: [...event.content, { type: "text", text: claimNotice }] };
106
- }
123
+ if (!isBashToolResult(event)) return undefined;
124
+
125
+ const command = event.input.command || "";
126
+ const sessionId = getSessionId(ctx);
127
+ const cwd = getCwd(ctx);
128
+
129
+ // Auto-claim on bd update --claim regardless of exit code.
130
+ if (/\bbd\s+update\b/.test(command) && /--claim\b/.test(command)) {
131
+ const issueMatch = command.match(/\bbd\s+update\s+(\S+)/);
132
+ if (issueMatch) {
133
+ const issueId = issueMatch[1];
134
+ await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
135
+ memoryGateFired = false;
136
+ const claimNotice = `\n\n✅ **Beads**: Session \`${sessionId}\` claimed issue \`${issueId}\`. File edits are now unblocked.`;
137
+ return { content: [...event.content, { type: "text", text: claimNotice }] };
107
138
  }
139
+ }
108
140
 
109
- if (command && /\bbd\s+close\b/.test(command) && !event.isError) {
110
- const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
111
- const newContent = [...event.content, { type: "text", text: reminder }];
112
- return { content: newContent };
141
+ if (/\bbd\s+close\b/.test(command) && !event.isError) {
142
+ const closeMatch = command.match(/\bbd\s+close\s+(\S+)/);
143
+ const closedIssueId = closeMatch?.[1] ?? null;
144
+ if (closedIssueId) {
145
+ await SubprocessRunner.run("bd", ["kv", "set", `closed-this-session:${sessionId}`, closedIssueId], { cwd });
146
+ memoryGateFired = false;
113
147
  }
148
+ const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
149
+ return { content: [...event.content, { type: "text", text: reminder }] };
114
150
  }
151
+
115
152
  return undefined;
116
153
  });
117
154
 
118
- // Dual safety net: warn about unclosed claims when session ends (non-blocking)
119
- const notifySessionEnd = async (ctx: any) => {
155
+ // Memory gate: if this session closed an issue, prompt for insight persistence.
156
+ // Uses sendUserMessage to trigger a new turn in Pi (non-blocking alternative to Claude Stop hook).
157
+ const triggerMemoryGateIfNeeded = async (ctx: any) => {
120
158
  const cwd = getCwd(ctx);
121
159
  if (!EventAdapter.isBeadsProject(cwd)) return;
122
160
  const sessionId = getSessionId(ctx);
123
- const claim = await getSessionClaim(sessionId, cwd);
124
- if (!claim) return;
125
161
 
126
- const message = `Beads: session ending with active claim [${claim}]`;
127
- if (ctx.hasUI) {
128
- ctx.ui.notify(message, "warning");
129
- } else {
130
- logger.warn(message);
162
+ const markerPath = join(cwd, ".beads", ".memory-gate-done");
163
+ if (existsSync(markerPath)) {
164
+ try { unlinkSync(markerPath); } catch { /* ignore */ }
165
+ await clearSessionMarkers(sessionId, cwd);
166
+ memoryGateFired = false;
167
+ return;
131
168
  }
132
- };
133
169
 
134
- // Memory gate: if the session's claimed issue was closed, prompt for insights.
135
- // Uses sendUserMessage to trigger a new agent turn (Pi has no blocking stop hook).
136
- // memoryGateFired prevents re-triggering on the follow-up agent_end.
137
- const triggerMemoryGateIfNeeded = async (ctx: any) => {
138
170
  if (memoryGateFired) return;
139
- const cwd = getCwd(ctx);
140
- if (!EventAdapter.isBeadsProject(cwd)) return;
141
- const sessionId = getSessionId(ctx);
142
- const claimId = await getSessionClaim(sessionId, cwd);
143
- if (!claimId) return;
144
171
 
145
- const result = await SubprocessRunner.run("bd", ["list", "--status=closed"], { cwd });
146
- if (result.code !== 0 || !result.stdout.includes(claimId)) return;
172
+ const closedIssueId = await getClosedThisSession(sessionId, cwd);
173
+ if (!closedIssueId) return;
147
174
 
148
175
  memoryGateFired = true;
149
176
  pi.sendUserMessage(
150
- `🧠 Memory gate: claim \`${claimId}\` was closed this session.\n` +
177
+ `🧠 Memory gate: claim \`${closedIssueId}\` was closed this session.\n` +
151
178
  `For each closed issue, worth persisting?\n` +
152
179
  ` YES → \`bd remember "<insight>"\`\n` +
153
180
  ` NO → note "nothing to persist"\n` +
@@ -156,13 +183,12 @@ export default function (pi: ExtensionAPI) {
156
183
  };
157
184
 
158
185
  pi.on("agent_end", async (_event, ctx) => {
159
- await notifySessionEnd(ctx);
160
186
  await triggerMemoryGateIfNeeded(ctx);
161
187
  return undefined;
162
188
  });
163
189
 
164
190
  pi.on("session_shutdown", async (_event, ctx) => {
165
- await notifySessionEnd(ctx);
191
+ await triggerMemoryGateIfNeeded(ctx);
166
192
  return undefined;
167
193
  });
168
194
  }
@@ -99,11 +99,10 @@ export default function (pi: ExtensionAPI) {
99
99
  return "";
100
100
  };
101
101
 
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();
102
+ let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
106
103
 
104
+ const applyCustomFooter = (ctx: any) => {
105
+ capturedCtx = ctx;
107
106
  ctx.ui.setFooter((tui, theme, footerData) => {
108
107
  requestRender = () => tui.requestRender();
109
108
  const unsub = footerData.onBranchChange(() => tui.requestRender());
@@ -133,7 +132,6 @@ export default function (pi: ExtensionAPI) {
133
132
  const modelChip = chip(modelId);
134
133
 
135
134
  const sep = " ";
136
-
137
135
  const brandModel = `${brand} ${modelChip}`;
138
136
  const beadChip = buildBeadChip();
139
137
  const leftParts = [brandModel, usageStr];
@@ -141,11 +139,46 @@ export default function (pi: ExtensionAPI) {
141
139
  leftParts.push(cwdStr);
142
140
  if (branchStr) leftParts.push(branchStr);
143
141
 
144
- const left = leftParts.join(sep);
145
- return [truncateToWidth(left, width)];
142
+ return [truncateToWidth(leftParts.join(sep), width)];
146
143
  },
147
144
  };
148
145
  });
146
+ };
147
+
148
+ const scheduleFooterReapply = (ctx: any, delayMs = 40) => {
149
+ if (footerReapplyTimer) clearTimeout(footerReapplyTimer);
150
+ footerReapplyTimer = setTimeout(() => {
151
+ applyCustomFooter(ctx);
152
+ footerReapplyTimer = null;
153
+ }, delayMs);
154
+ };
155
+
156
+ pi.on("session_start", async (_event, ctx) => {
157
+ capturedCtx = ctx;
158
+ // Get session ID from sessionManager/context (prefer UUID, consistent with hooks)
159
+ sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
160
+ applyCustomFooter(ctx);
161
+ // pi-dex reapplies footer on session start with setTimeout(0); reclaim after that pass.
162
+ scheduleFooterReapply(ctx);
163
+ });
164
+
165
+ pi.on("session_switch", async (_event, ctx) => {
166
+ scheduleFooterReapply(ctx);
167
+ });
168
+
169
+ pi.on("session_fork", async (_event, ctx) => {
170
+ scheduleFooterReapply(ctx);
171
+ });
172
+
173
+ pi.on("model_select", async (_event, ctx) => {
174
+ scheduleFooterReapply(ctx);
175
+ });
176
+
177
+ pi.on("session_shutdown", async () => {
178
+ if (footerReapplyTimer) {
179
+ clearTimeout(footerReapplyTimer);
180
+ footerReapplyTimer = null;
181
+ }
149
182
  });
150
183
 
151
184
  // Bust the bead cache immediately after any bd write
@@ -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;