termbeam 1.18.1 → 1.19.1

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.
Files changed (82) hide show
  1. package/package.json +4 -1
  2. package/public/assets/{_basePickBy-SVV3-IdA.js → _basePickBy-D2x9UR-Z.js} +1 -1
  3. package/public/assets/{_baseUniq-DOa_cDXJ.js → _baseUniq-C01jsmVS.js} +1 -1
  4. package/public/assets/{arc-CiPooz6h.js → arc-t3uO9VFT.js} +1 -1
  5. package/public/assets/architectureDiagram-Q4EWVU46-DWUIuXit.js +36 -0
  6. package/public/assets/{blockDiagram-WCTKOSBZ-BmfWKjiE.js → blockDiagram-DXYQGD6D-DnP4lNOB.js} +6 -6
  7. package/public/assets/c4Diagram-AHTNJAMY-B29P8b7E.js +10 -0
  8. package/public/assets/channel-Du2155FM.js +1 -0
  9. package/public/assets/{chunk-4BX2VUAB-DslNFup8.js → chunk-4BX2VUAB-BH7Ixc1K.js} +1 -1
  10. package/public/assets/chunk-4TB4RGXK-h7uQ9ZtR.js +206 -0
  11. package/public/assets/{chunk-55IACEB6-DF8ZBVw6.js → chunk-55IACEB6-D9ZHEhWx.js} +1 -1
  12. package/public/assets/{chunk-KX2RTZJC-DR85cVBM.js → chunk-EDXVE4YY-BEKltVR7.js} +1 -1
  13. package/public/assets/{chunk-FMBD7UC4-CVO2u-Sv.js → chunk-FMBD7UC4-BPkcv-bj.js} +1 -1
  14. package/public/assets/chunk-OYMX7WX6-C-wnBny1.js +231 -0
  15. package/public/assets/{chunk-QZHKN3VN-D1hlvsPG.js → chunk-QZHKN3VN-DBZnU2yp.js} +1 -1
  16. package/public/assets/{chunk-JSJVCQXG-CdSwCmzy.js → chunk-YZCP3GAM-C8GNavGc.js} +1 -1
  17. package/public/assets/classDiagram-6PBFFD2Q-Dzf6e5xB.js +1 -0
  18. package/public/assets/classDiagram-v2-HSJHXN6E-Dzf6e5xB.js +1 -0
  19. package/public/assets/clone-VT9_rs7L.js +1 -0
  20. package/public/assets/{cose-bilkent-S5V4N54A-BHgn8K1Z.js → cose-bilkent-S5V4N54A-BeFh7BYc.js} +1 -1
  21. package/public/assets/{dagre-KLK3FWXG-CK-UftHZ.js → dagre-KV5264BT-DlsYCBSj.js} +2 -2
  22. package/public/assets/diagram-5BDNPKRD-CnTlMSc9.js +10 -0
  23. package/public/assets/diagram-G4DWMVQ6-CKODi7zI.js +24 -0
  24. package/public/assets/diagram-MMDJMWI5-DEJGgmOX.js +43 -0
  25. package/public/assets/{diagram-P4PSJMXO-Cb0uoPeh.js → diagram-TYMM5635-Dju-tIVS.js} +1 -1
  26. package/public/assets/erDiagram-SMLLAGMA-CqPQSqot.js +85 -0
  27. package/public/assets/flowDiagram-DWJPFMVM-BeIRzZQp.js +162 -0
  28. package/public/assets/{ganttDiagram-A5KZAMGK-oBrRehQA.js → ganttDiagram-T4ZO3ILL-B6BnA7VR.js} +4 -4
  29. package/public/assets/gitGraphDiagram-UUTBAWPF-BoSi7fJX.js +106 -0
  30. package/public/assets/{graph-NJUd-2rr.js → graph-uVutBrOm.js} +1 -1
  31. package/public/assets/index-C0J_Dxjj.css +32 -0
  32. package/public/assets/index-NvPavSM9.js +447 -0
  33. package/public/assets/{infoDiagram-LFFYTUFH-C0cd4K10.js → infoDiagram-42DDH7IO-DD-KdApo.js} +1 -1
  34. package/public/assets/{ishikawaDiagram-PHBUUO56-Ny_4n8vD.js → ishikawaDiagram-UXIWVN3A-D36iFaUH.js} +2 -2
  35. package/public/assets/journeyDiagram-VCZTEJTY-BMQDm-H-.js +139 -0
  36. package/public/assets/{kanban-definition-K7BYSVSG-DDGFShX4.js → kanban-definition-6JOO6SKY-D1FZXkK7.js} +8 -8
  37. package/public/assets/{layout-DTAwaKXg.js → layout-xVUStQT2.js} +1 -1
  38. package/public/assets/{linear-CSYWJnE5.js → linear-BTv56PNK.js} +1 -1
  39. package/public/assets/mindmap-definition-QFDTVHPH-CvhBJGrR.js +96 -0
  40. package/public/assets/pieDiagram-DEJITSTG-DcxBOIJ2.js +30 -0
  41. package/public/assets/{quadrantDiagram-337W2JSQ-DWGyMcZ6.js → quadrantDiagram-34T5L4WZ-D79TxdrP.js} +1 -1
  42. package/public/assets/requirementDiagram-MS252O5E-gOOiR6tu.js +84 -0
  43. package/public/assets/{sankeyDiagram-WA2Y5GQK-GoTjgmGo.js → sankeyDiagram-XADWPNL6-YUncdO2g.js} +1 -1
  44. package/public/assets/sequenceDiagram-FGHM5R23-eoBFRqV1.js +157 -0
  45. package/public/assets/stateDiagram-FHFEXIEX-DeQeLuN0.js +1 -0
  46. package/public/assets/stateDiagram-v2-QKLJ7IA2-BhqrHPnX.js +1 -0
  47. package/public/assets/timeline-definition-GMOUNBTQ-CV0p2TOx.js +120 -0
  48. package/public/assets/{vennDiagram-LZ73GAT5-DzlX55oz.js → vennDiagram-DHZGUBPP-CciIt7hk.js} +5 -5
  49. package/public/assets/wardley-RL74JXVD-DpAn0g0p.js +162 -0
  50. package/public/assets/wardleyDiagram-NUSXRM2D-BcEpTQV4.js +20 -0
  51. package/public/assets/xychartDiagram-5P7HB3ND-B-PklpIN.js +7 -0
  52. package/public/index.html +2 -2
  53. package/public/sw.js +1 -1
  54. package/src/server/routes.js +48 -0
  55. package/src/utils/agent-sessions.js +288 -0
  56. package/src/utils/agents.js +118 -0
  57. package/src/utils/update-check.js +19 -0
  58. package/public/assets/architectureDiagram-2XIMDMQ5-DLhsY97x.js +0 -36
  59. package/public/assets/c4Diagram-IC4MRINW-CHJjiNgt.js +0 -10
  60. package/public/assets/channel-BK2nwbl-.js +0 -1
  61. package/public/assets/chunk-NQ4KR5QH-DENB8fT9.js +0 -220
  62. package/public/assets/chunk-WL4C6EOR-BFkfV9d-.js +0 -189
  63. package/public/assets/classDiagram-VBA2DB6C-Ca3s5d8r.js +0 -1
  64. package/public/assets/classDiagram-v2-RAHNMMFH-Ca3s5d8r.js +0 -1
  65. package/public/assets/clone-Zyw2C-Y3.js +0 -1
  66. package/public/assets/diagram-E7M64L7V-B7XX1Mqr.js +0 -24
  67. package/public/assets/diagram-IFDJBPK2-DtSlXz7u.js +0 -43
  68. package/public/assets/erDiagram-INFDFZHY-D2aAhxDx.js +0 -70
  69. package/public/assets/flowDiagram-PKNHOUZH-BxCqn1aV.js +0 -162
  70. package/public/assets/gitGraphDiagram-K3NZZRJ6-CMuK7hOw.js +0 -65
  71. package/public/assets/index-BzV_FYAW.css +0 -32
  72. package/public/assets/index-DBnflEQZ.js +0 -394
  73. package/public/assets/journeyDiagram-4ABVD52K-2QGufcXt.js +0 -139
  74. package/public/assets/mindmap-definition-YRQLILUH-C6Jy7Tz1.js +0 -68
  75. package/public/assets/pieDiagram-SKSYHLDU-DCNv1DYe.js +0 -30
  76. package/public/assets/requirementDiagram-Z7DCOOCP-BC1_RS6p.js +0 -73
  77. package/public/assets/sequenceDiagram-2WXFIKYE-CjTa5Eua.js +0 -145
  78. package/public/assets/stateDiagram-RAJIS63D-wf6b0MTk.js +0 -1
  79. package/public/assets/stateDiagram-v2-FVOUBMTO-lYMaTr7v.js +0 -1
  80. package/public/assets/timeline-definition-YZTLITO2-BHWYxexH.js +0 -61
  81. package/public/assets/treemap-KZPCXAKY-0OLZO-76.js +0 -162
  82. package/public/assets/xychartDiagram-JWTSCODW-DafJ-a-f.js +0 -7
@@ -0,0 +1,288 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const log = require('./logger');
5
+
6
+ /**
7
+ * Read Copilot sessions from SQLite store.
8
+ * Returns array of { id, agent, summary, cwd, repo, branch, updatedAt, turnCount }
9
+ */
10
+ function readCopilotSessions(limit = 50) {
11
+ let Database;
12
+ try {
13
+ Database = require('better-sqlite3');
14
+ } catch {
15
+ return [];
16
+ }
17
+ const dbPath = path.join(os.homedir(), '.copilot', 'session-store.db');
18
+ if (!fs.existsSync(dbPath)) return [];
19
+
20
+ try {
21
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
22
+ const sessions = db
23
+ .prepare(
24
+ `
25
+ SELECT s.id, s.summary, s.cwd, s.repository, s.branch, s.updated_at,
26
+ (SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id) as turn_count,
27
+ (SELECT substr(t.user_message, 1, 200) FROM turns t WHERE t.session_id = s.id ORDER BY t.turn_index ASC LIMIT 1) as first_msg
28
+ FROM sessions s
29
+ ORDER BY s.updated_at DESC
30
+ LIMIT ?
31
+ `,
32
+ )
33
+ .all(limit);
34
+ db.close();
35
+
36
+ return sessions
37
+ .filter((s) => s.turn_count > 0)
38
+ .map((s) => ({
39
+ id: s.id,
40
+ agent: 'copilot',
41
+ agentName: 'GitHub Copilot',
42
+ agentIcon: 'copilot',
43
+ summary: s.summary || s.first_msg || null,
44
+ cwd: s.cwd || null,
45
+ repo: s.repository || null,
46
+ branch: s.branch || null,
47
+ updatedAt: s.updated_at || null,
48
+ turnCount: s.turn_count || 0,
49
+ }));
50
+ } catch (err) {
51
+ log.warn(`Failed to read Copilot sessions: ${err.message}`);
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read Claude Code sessions from JSONL files.
58
+ * Returns array of unified session objects.
59
+ */
60
+ function readClaudeSessions(limit = 50) {
61
+ const baseDir = path.join(os.homedir(), '.claude', 'projects');
62
+ if (!fs.existsSync(baseDir)) return [];
63
+
64
+ try {
65
+ const sessions = [];
66
+ const projectDirs = fs.readdirSync(baseDir);
67
+
68
+ for (const projDir of projectDirs) {
69
+ const fullProjDir = path.join(baseDir, projDir);
70
+ try {
71
+ if (!fs.statSync(fullProjDir).isDirectory()) continue;
72
+ } catch {
73
+ continue; // Directory may have been removed
74
+ }
75
+
76
+ // Decode CWD from directory name: -Users-foo-bar → /Users/foo/bar
77
+ const cwd = projDir.replace(/^-/, '/').replace(/-/g, '/');
78
+
79
+ const jsonlFiles = fs
80
+ .readdirSync(fullProjDir)
81
+ .filter((f) => f.endsWith('.jsonl'))
82
+ .map((f) => {
83
+ const fullPath = path.join(fullProjDir, f);
84
+ const stat = fs.statSync(fullPath);
85
+ return { file: f, path: fullPath, mtime: stat.mtime, size: stat.size };
86
+ })
87
+ .sort((a, b) => b.mtime - a.mtime);
88
+
89
+ for (const fileInfo of jsonlFiles.slice(0, 10)) {
90
+ try {
91
+ const sessionId = path.basename(fileInfo.file, '.jsonl');
92
+
93
+ // Read file — cap at 100KB to avoid blocking the event loop on large JSONL files
94
+ let content;
95
+ if (fileInfo.size > 100_000) {
96
+ const fd = fs.openSync(fileInfo.path, 'r');
97
+ const buf = Buffer.alloc(100_000);
98
+ const bytesRead = fs.readSync(fd, buf, 0, 100_000, 0);
99
+ fs.closeSync(fd);
100
+ content = buf.toString('utf8', 0, bytesRead);
101
+ } else {
102
+ content = fs.readFileSync(fileInfo.path, 'utf8');
103
+ }
104
+ const rawLines = content.split('\n');
105
+
106
+ let cwdFromFile = cwd;
107
+ let branch = null;
108
+ let userTurnCount = 0;
109
+ let firstUserMsg = null;
110
+
111
+ for (const line of rawLines) {
112
+ if (!line.trim()) continue;
113
+ try {
114
+ const entry = JSON.parse(line);
115
+ if (!branch && entry.gitBranch) branch = entry.gitBranch;
116
+ if (entry.cwd) cwdFromFile = entry.cwd;
117
+ if (entry.type === 'user') {
118
+ userTurnCount++;
119
+ if (!firstUserMsg) {
120
+ // Claude stores user message at entry.message.content (not entry.data)
121
+ const msg = entry.message;
122
+ if (msg && typeof msg === 'object') {
123
+ const content = msg.content;
124
+ if (typeof content === 'string') {
125
+ // Skip meta/command messages (XML-tagged system entries)
126
+ if (!content.startsWith('<') && content.trim().length > 5) {
127
+ firstUserMsg = content.slice(0, 200);
128
+ }
129
+ } else if (Array.isArray(content)) {
130
+ for (const item of content) {
131
+ if (item && item.type === 'text' && typeof item.text === 'string') {
132
+ if (!item.text.startsWith('<') && item.text.trim().length > 5) {
133
+ firstUserMsg = item.text.slice(0, 200);
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ } catch {
143
+ // skip malformed line
144
+ }
145
+ }
146
+
147
+ // Skip empty sessions (no user interaction)
148
+ if (userTurnCount === 0) continue;
149
+
150
+ sessions.push({
151
+ id: sessionId,
152
+ agent: 'claude',
153
+ agentName: 'Claude Code',
154
+ agentIcon: 'claude',
155
+ summary: firstUserMsg || null,
156
+ cwd: cwdFromFile,
157
+ repo: null,
158
+ branch,
159
+ updatedAt: fileInfo.mtime.toISOString(),
160
+ turnCount: userTurnCount,
161
+ });
162
+ } catch (err) {
163
+ log.debug(`Failed to parse Claude session ${fileInfo.file}: ${err.message}`);
164
+ }
165
+ }
166
+ }
167
+
168
+ // Sort by updated time descending
169
+ sessions.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
170
+ return sessions.slice(0, limit);
171
+ } catch (err) {
172
+ log.warn(`Failed to read Claude sessions: ${err.message}`);
173
+ return [];
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Read OpenCode sessions from SQLite store.
179
+ * DB at ~/.local/share/opencode/opencode.db
180
+ */
181
+ function readOpenCodeSessions(limit = 50) {
182
+ let Database;
183
+ try {
184
+ Database = require('better-sqlite3');
185
+ } catch {
186
+ return [];
187
+ }
188
+ const dbPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
189
+ if (!fs.existsSync(dbPath)) return [];
190
+
191
+ try {
192
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
193
+ const sessions = db
194
+ .prepare(
195
+ `
196
+ SELECT s.id, s.title, s.directory, s.time_created, s.time_updated,
197
+ (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) as msg_count
198
+ FROM session s
199
+ WHERE s.time_archived IS NULL
200
+ ORDER BY s.time_updated DESC
201
+ LIMIT ?
202
+ `,
203
+ )
204
+ .all(limit);
205
+ db.close();
206
+
207
+ return sessions
208
+ .filter((s) => s.msg_count > 0)
209
+ .map((s) => ({
210
+ id: s.id,
211
+ agent: 'opencode',
212
+ agentName: 'OpenCode',
213
+ agentIcon: 'opencode',
214
+ summary: s.title || null,
215
+ cwd: s.directory || null,
216
+ repo: null,
217
+ branch: null,
218
+ updatedAt: s.time_updated || s.time_created || null,
219
+ turnCount: s.msg_count || 0,
220
+ }));
221
+ } catch (err) {
222
+ log.warn(`Failed to read OpenCode sessions: ${err.message}`);
223
+ return [];
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Get all agent sessions from all sources, unified and sorted.
229
+ */
230
+ async function getAgentSessions({ limit = 100, agent = null, search = null } = {}) {
231
+ const results = [];
232
+
233
+ if (!agent || agent === 'copilot') {
234
+ results.push(...readCopilotSessions(limit));
235
+ }
236
+ if (!agent || agent === 'claude') {
237
+ results.push(...readClaudeSessions(limit));
238
+ }
239
+ if (!agent || agent === 'opencode') {
240
+ results.push(...readOpenCodeSessions(limit));
241
+ }
242
+
243
+ // Sort all by updatedAt descending
244
+ results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
245
+
246
+ // Apply search filter (case-insensitive substring match)
247
+ if (search) {
248
+ const q = search.toLowerCase();
249
+ const filtered = results.filter(
250
+ (s) =>
251
+ (s.summary && s.summary.toLowerCase().includes(q)) ||
252
+ (s.cwd && s.cwd.toLowerCase().includes(q)) ||
253
+ (s.repo && s.repo.toLowerCase().includes(q)) ||
254
+ (s.branch && s.branch.toLowerCase().includes(q)),
255
+ );
256
+ return filtered.slice(0, limit);
257
+ }
258
+
259
+ return results.slice(0, limit);
260
+ }
261
+
262
+ /**
263
+ * Build the resume command for a given agent session.
264
+ */
265
+ function getResumeCommand(session) {
266
+ // Validate session ID to prevent command injection
267
+ // UUID format for copilot/claude, ses_xxx format for opencode
268
+ if (!/^[a-z0-9_-]{8,}$/i.test(session.id)) return null;
269
+
270
+ switch (session.agent) {
271
+ case 'copilot':
272
+ return `copilot --resume=${session.id}`;
273
+ case 'claude':
274
+ return `claude --resume ${session.id}`;
275
+ case 'opencode':
276
+ return `opencode --session ${session.id}`;
277
+ default:
278
+ return null;
279
+ }
280
+ }
281
+
282
+ module.exports = {
283
+ getAgentSessions,
284
+ getResumeCommand,
285
+ readCopilotSessions,
286
+ readClaudeSessions,
287
+ readOpenCodeSessions,
288
+ };
@@ -0,0 +1,118 @@
1
+ const child_process = require('child_process');
2
+ const os = require('os');
3
+ const log = require('./logger');
4
+
5
+ const KNOWN_AGENTS = [
6
+ {
7
+ id: 'copilot',
8
+ name: 'GitHub Copilot',
9
+ cmd: 'copilot',
10
+ icon: 'copilot',
11
+ detect: ['copilot', ['--version']],
12
+ },
13
+ {
14
+ id: 'gh-copilot',
15
+ name: 'GitHub Copilot (gh)',
16
+ cmd: 'gh',
17
+ args: ['copilot'],
18
+ icon: 'copilot',
19
+ detect: ['gh', ['copilot', '--version']],
20
+ },
21
+ {
22
+ id: 'claude',
23
+ name: 'Claude Code',
24
+ cmd: 'claude',
25
+ icon: 'claude',
26
+ detect: ['claude', ['--version']],
27
+ },
28
+ {
29
+ id: 'aider',
30
+ name: 'Aider',
31
+ cmd: 'aider',
32
+ icon: 'aider',
33
+ detect: ['aider', ['--version']],
34
+ },
35
+ {
36
+ id: 'codex',
37
+ name: 'Codex CLI',
38
+ cmd: 'codex',
39
+ icon: 'codex',
40
+ detect: ['codex', ['--version']],
41
+ },
42
+ {
43
+ id: 'opencode',
44
+ name: 'OpenCode',
45
+ cmd: 'opencode',
46
+ icon: 'opencode',
47
+ detect: ['opencode', ['--version']],
48
+ },
49
+ ];
50
+
51
+ let cachedAgents = null;
52
+ let cacheTime = 0;
53
+ const CACHE_TTL = 60_000; // 60 seconds
54
+
55
+ function tryDetectAgent(agent) {
56
+ const [cmd, args] = agent.detect;
57
+ const isWindows = os.platform() === 'win32';
58
+ const candidates = isWindows ? [cmd, `${cmd}.cmd`, `${cmd}.exe`] : [cmd];
59
+
60
+ return new Promise((resolve) => {
61
+ let resolved = false;
62
+ let remaining = candidates.length;
63
+
64
+ for (const bin of candidates) {
65
+ child_process.execFile(bin, args, { timeout: 5000, encoding: 'utf8' }, (err, stdout) => {
66
+ remaining--;
67
+ if (resolved) return;
68
+ if (!err) {
69
+ resolved = true;
70
+ const version = (stdout || '').trim().split('\n')[0] || 'unknown';
71
+ resolve({
72
+ id: agent.id,
73
+ name: agent.name,
74
+ cmd: agent.cmd,
75
+ args: agent.args || [],
76
+ icon: agent.icon,
77
+ version,
78
+ });
79
+ } else if (remaining === 0) {
80
+ resolve(null);
81
+ }
82
+ });
83
+ }
84
+ });
85
+ }
86
+
87
+ async function detectAgents() {
88
+ log.debug('Detecting available AI agents...');
89
+ const results = await Promise.allSettled(KNOWN_AGENTS.map(tryDetectAgent));
90
+
91
+ const agents = [];
92
+ for (const result of results) {
93
+ if (result.status === 'fulfilled' && result.value) {
94
+ agents.push(result.value);
95
+ }
96
+ }
97
+
98
+ // Deduplicate: prefer standalone copilot over gh copilot
99
+ const hasCopilot = agents.some((a) => a.id === 'copilot');
100
+ const deduped = hasCopilot ? agents.filter((a) => a.id !== 'gh-copilot') : agents;
101
+
102
+ log.debug(
103
+ `Detected ${deduped.length} AI agent(s): ${deduped.map((a) => a.name).join(', ') || 'none'}`,
104
+ );
105
+ return deduped;
106
+ }
107
+
108
+ async function getAvailableAgents() {
109
+ const now = Date.now();
110
+ if (cachedAgents && now - cacheTime < CACHE_TTL) {
111
+ return cachedAgents;
112
+ }
113
+ cachedAgents = await detectAgents();
114
+ cacheTime = Date.now();
115
+ return cachedAgents;
116
+ }
117
+
118
+ module.exports = { detectAgents, getAvailableAgents, KNOWN_AGENTS };
@@ -78,6 +78,16 @@ function normalizeVersion(version) {
78
78
  * Returns false if either version cannot be parsed.
79
79
  */
80
80
  function isNewerVersion(current, latest) {
81
+ // Dev builds (e.g. 1.18.1-dev+dirty) of the same base version are running
82
+ // from source — never prompt to "update" to the same stable release.
83
+ if (isDevBuild(current)) {
84
+ const cur = normalizeVersion(current);
85
+ const lat = normalizeVersion(latest);
86
+ if (!cur || !lat) return false;
87
+ const sameBase = cur[0] === lat[0] && cur[1] === lat[1] && cur[2] === lat[2];
88
+ if (sameBase) return false;
89
+ // Different base version — fall through to normal comparison
90
+ }
81
91
  const cur = normalizeVersion(current);
82
92
  const lat = normalizeVersion(latest);
83
93
  if (!cur || !lat) return false;
@@ -104,6 +114,15 @@ function isPreRelease(version) {
104
114
  return v.includes('-');
105
115
  }
106
116
 
117
+ /**
118
+ * Check if a version is a local dev build (e.g. "1.18.1-dev+dirty", "1.18.1-dev.3+abcdef").
119
+ * Dev builds should never trigger update prompts — they're running from source.
120
+ */
121
+ function isDevBuild(version) {
122
+ if (typeof version !== 'string') return false;
123
+ return /-(dev|dirty)/.test(version) || /\+(dirty|dev)/.test(version);
124
+ }
125
+
107
126
  /**
108
127
  * Strip ANSI escape sequences and control characters from a string.
109
128
  * Prevents terminal injection if the registry returns malicious data.