pi-background-tasks 0.2.0 → 0.4.0

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.
@@ -3,22 +3,467 @@ import { open } from "node:fs/promises";
3
3
  import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  export type TaskStatus = "running" | "completed" | "failed" | "killed";
6
- export type TaskContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
7
- export type BgTaskSnapshot = { id: string; name?: string; command: string; description?: string; status: TaskStatus; outputPath: string; cwd: string; startTime: number; endTime?: number; exitCode?: number | null; signal?: string | null; pid?: number; bytesWritten: number; error?: string; notified: boolean; notifyOnCompletion: boolean; triggerOnCompletion: boolean; timeoutSeconds?: number; contextUsage?: TaskContextUsage };
6
+ export type KillKind = "user" | "timeout" | "output_cap" | "shutdown";
7
+
8
+ export type TaskContextUsage = {
9
+ tokens: number | null;
10
+ contextWindow: number;
11
+ percent: number | null;
12
+ };
13
+
14
+ export type TaskTokenUsage = {
15
+ input: number;
16
+ output: number;
17
+ cacheRead: number;
18
+ cacheWrite: number;
19
+ totalTokens: number;
20
+ costTotal?: number;
21
+ };
22
+
23
+ export type TaskToolUsage = {
24
+ total: number;
25
+ failed: number;
26
+ byName: Record<string, number>;
27
+ };
28
+
29
+ export type BgTaskSnapshot = {
30
+ id: string;
31
+ name?: string | undefined;
32
+ command: string;
33
+ description?: string | undefined;
34
+ status: TaskStatus;
35
+ outputPath: string;
36
+ cwd: string;
37
+ startTime: number;
38
+ endTime?: number | undefined;
39
+ exitCode?: number | null | undefined;
40
+ signal?: string | null | undefined;
41
+ pid?: number | undefined;
42
+ bytesWritten: number;
43
+ isAgent: boolean;
44
+ error?: string | undefined;
45
+ notified: boolean;
46
+ notifyOnCompletion: boolean;
47
+ triggerOnCompletion: boolean;
48
+ timeoutSeconds?: number | undefined;
49
+ contextUsage?: TaskContextUsage | undefined;
50
+ tokenUsage?: TaskTokenUsage | undefined;
51
+ toolUsage?: TaskToolUsage | undefined;
52
+ model?: string | undefined;
53
+ };
54
+
55
+ export type BgTask = Omit<BgTaskSnapshot, "name"> & {
56
+ name: string;
57
+ outputAbsPath: string;
58
+ metadataAbsPath: string;
59
+ child?: import("./registry.js").BackgroundTaskChildProcess | undefined;
60
+ stream?: import("node:fs").WriteStream | undefined;
61
+ timeoutHandle?: NodeJS.Timeout | undefined;
62
+ killKind?: KillKind | undefined;
63
+ killSignalSent?: boolean | undefined;
64
+ capExceeded?: boolean | undefined;
65
+ finalized?: boolean | undefined;
66
+ contextUsageBuffer?: string | undefined;
67
+ waiters: Array<() => void>;
68
+ };
69
+
70
+ export type BgRunDetails = {
71
+ task: BgTaskSnapshot;
72
+ };
73
+
74
+ export type BgStatusDetails = {
75
+ tasks: BgTaskSnapshot[];
76
+ };
77
+
78
+ export type BgLogsDetails = {
79
+ task: BgTaskSnapshot;
80
+ path: string;
81
+ bytesRead: number;
82
+ truncated: boolean;
83
+ tail: boolean;
84
+ };
85
+
86
+ export type BgKillDetails = {
87
+ task: BgTaskSnapshot;
88
+ message: string;
89
+ };
90
+
91
+ export type StartTaskOptions = {
92
+ name?: string | undefined;
93
+ description?: string | undefined;
94
+ isAgent?: boolean | undefined;
95
+ timeoutSeconds?: number | undefined;
96
+ notifyOnCompletion?: boolean | undefined;
97
+ triggerOnCompletion?: boolean | undefined;
98
+ };
99
+
8
100
  export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
9
101
  export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
10
102
  export const COMMAND_PREVIEW_CHARS = 90;
11
- export function sanitizePathSegment(value: string): string { return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "session"; }
12
- export function stripMatchingQuotes(value: string): string { const t=value.trim(); if(t.length>=2&&((t[0]==='"'&&t.at(-1)==='"')||(t[0]==="'"&&t.at(-1)==="'"))) return t.slice(1,-1); return t; }
13
- export function compactWhitespace(value: string): string { return value.replace(/\s+/g," ").trim(); }
14
- export function truncateChars(value: string, maxChars: number): string { return value.length<=maxChars?value:`${value.slice(0,Math.max(0,maxChars-1))}…`; }
15
- export function normalizeTaskName(value: unknown): string|undefined { if(typeof value!=="string") return undefined; const n=compactWhitespace(stripMatchingQuotes(value)); return n?truncateChars(n,80):undefined; }
16
- export function deriveTaskNameFromCommand(command: string): string { const n=compactWhitespace(stripMatchingQuotes(command)); if(!n) return "Background task"; const m=n.match(/^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/); if(m) return truncateChars(`${m[1]}${m[2]?" run":""} ${m[3]}`,48); return truncateChars(n.split(/\s+/).slice(0,5).join(" ")||n,48); }
17
- export function taskDisplayName(task: { name?: string; description?: string; command?: string; id?: string }): string { return normalizeTaskName(task.name) ?? normalizeTaskName(task.description) ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined) ?? task.id ?? "Background task"; }
18
- function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string }|undefined { const input=valueAndRest.trimStart(); if(!input) return undefined; const q=input[0]; if(q==='"'||q==="'"){ let escaped=false, value=""; for(let i=1;i<input.length;i++){ const c=input[i]!; if(escaped){value+=c; escaped=false; continue;} if(c==="\\"){escaped=true; continue;} if(c===q) return {value, rest: input.slice(i+1).trimStart()}; value+=c;} return undefined;} const m=input.match(/^(\S+)(?:\s+([\s\S]*))?$/); return m?{value:m[1]!, rest:m[2]?.trimStart()??""}:undefined; }
19
- export function parseBgCommandArgs(args: string): { name?: string; command: string } { const input=args.trim(); for(const prefix of ["--name=","-n="]){ if(input.startsWith(prefix)){ const p=parseNameValueAndRest(input.slice(prefix.length)); if(!p) throw new Error(`${prefix.slice(0,-1)} requires a task name`); return {name:normalizeTaskName(p.value), command:p.rest}; }} for(const prefix of ["--name","-n"]){ if(input===prefix||input.startsWith(`${prefix} `)||input.startsWith(`${prefix}\t`)){ const p=parseNameValueAndRest(input.slice(prefix.length)); if(!p) throw new Error(`${prefix} requires a task name`); return {name:normalizeTaskName(p.value), command:p.rest}; }} return {command: input}; }
20
- export function formatDuration(ms: number): string { if(ms<1000) return `${ms}ms`; const s=Math.floor(ms/1000); if(s<60) return `${s}s`; const m=Math.floor(s/60), rs=s%60; if(m<60) return `${m}m${rs?`${rs}s`:""}`; const h=Math.floor(m/60), rm=m%60; return `${h}h${rm?`${rm}m`:""}`; }
21
- export function shellInvocation(command: string): { shell: string; args: string[] } { return process.platform === "win32" ? { shell: process.env.ComSpec || "cmd.exe", args: ["/d","/s","/c",command] } : { shell: process.env.SHELL || "/bin/sh", args: ["-c", command] }; }
22
- export function normalizeMaxBytes(value: unknown, fallback=DEFAULT_LOG_BYTES): number { const raw=typeof value==="number"&&Number.isFinite(value)?Math.floor(value):fallback; return Math.max(1, Math.min(MAX_LOG_BYTES, raw)); }
23
- export function formatSnapshotList(tasks: BgTaskSnapshot[], now=Date.now()): string { if(tasks.length===0) return "No background tasks in this Pi extension runtime."; return tasks.map(t=>{ const icon=t.status==="running"?"▶":t.status==="completed"?"✓":t.status==="killed"?"■":"✗"; const age=formatDuration((t.endTime??now)-t.startTime); const code=t.exitCode!==undefined?` exit=${t.exitCode}`:""; const pid=t.pid?` pid=${t.pid}`:""; const error=t.error?` error=${truncateChars(t.error,80)}`:""; return `${icon} ${t.id} ${t.status} ${age}${code}${pid} — ${truncateChars(taskDisplayName(t),COMMAND_PREVIEW_CHARS)}${error}\n output: ${t.outputPath}`; }).join("\n"); }
24
- export async function boundedRead(filePath: string, maxBytes: number, tail: boolean): Promise<{content:string; truncated:boolean; bytesRead:number; totalBytes:number}> { const st=statSync(filePath); const totalBytes=st.size; const bytesToRead=Math.min(totalBytes,maxBytes); if(bytesToRead===0) return {content:"", truncated:false, bytesRead:0, totalBytes}; const f=await open(filePath,"r"); try{ const b=Buffer.alloc(bytesToRead); const pos=tail?Math.max(0,totalBytes-bytesToRead):0; const {bytesRead}=await f.read(b,0,bytesToRead,pos); return {content:b.subarray(0,bytesRead).toString("utf8"), truncated:totalBytes>bytesRead, bytesRead, totalBytes}; } finally { await f.close(); } }
103
+
104
+ export function sanitizePathSegment(value: string): string {
105
+ const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
106
+ return sanitized || "session";
107
+ }
108
+
109
+ export function stripMatchingQuotes(value: string): string {
110
+ const trimmed = value.trim();
111
+ if (trimmed.length >= 2) {
112
+ const first = trimmed[0];
113
+ const last = trimmed[trimmed.length - 1];
114
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
115
+ return trimmed.slice(1, -1);
116
+ }
117
+ }
118
+ return trimmed;
119
+ }
120
+
121
+ export function compactWhitespace(value: string): string {
122
+ return value.replace(/\s+/g, " ").trim();
123
+ }
124
+
125
+ export function truncateChars(value: string, maxChars: number): string {
126
+ if (value.length <= maxChars) return value;
127
+ return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
128
+ }
129
+
130
+ export function normalizeTaskName(value: unknown): string | undefined {
131
+ if (typeof value !== "string") return undefined;
132
+ const normalized = compactWhitespace(stripMatchingQuotes(value));
133
+ if (!normalized) return undefined;
134
+ return truncateChars(normalized, 80);
135
+ }
136
+
137
+ export function deriveTaskNameFromCommand(command: string): string {
138
+ const normalized = compactWhitespace(stripMatchingQuotes(command));
139
+ if (!normalized) return "Background task";
140
+
141
+ const packageScript = normalized.match(/^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/);
142
+ if (packageScript) {
143
+ const runner = packageScript[1];
144
+ const run = packageScript[2] ? " run" : "";
145
+ const script = packageScript[3];
146
+ return truncateChars(`${runner}${run} ${script}`, 48);
147
+ }
148
+
149
+ const words = normalized.split(/\s+/).slice(0, 5).join(" ");
150
+ return truncateChars(words || normalized, 48);
151
+ }
152
+
153
+ export function taskDisplayName(task: { name?: string | undefined; description?: string | undefined; command?: string | undefined; id?: string | undefined }): string {
154
+ return normalizeTaskName(task.name)
155
+ ?? normalizeTaskName(task.description)
156
+ ?? (task.command ? deriveTaskNameFromCommand(task.command) : undefined)
157
+ ?? task.id
158
+ ?? "Background task";
159
+ }
160
+
161
+ function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
162
+ const input = valueAndRest.trimStart();
163
+ if (!input) return undefined;
164
+ const quote = input[0];
165
+ if (quote === '"' || quote === "'") {
166
+ let escaped = false;
167
+ let value = "";
168
+ for (let i = 1; i < input.length; i++) {
169
+ const char = input.charAt(i);
170
+ if (escaped) {
171
+ value += char;
172
+ escaped = false;
173
+ continue;
174
+ }
175
+ if (char === "\\") {
176
+ escaped = true;
177
+ continue;
178
+ }
179
+ if (char === quote) {
180
+ return { value, rest: input.slice(i + 1).trimStart() };
181
+ }
182
+ value += char;
183
+ }
184
+ return undefined;
185
+ }
186
+ const match = input.match(/^(\S+)(?:\s+([\s\S]*))?$/);
187
+ if (!match) return undefined;
188
+ const parsedValue = match[1];
189
+ if (parsedValue === undefined) return undefined;
190
+ return { value: parsedValue, rest: match[2]?.trimStart() ?? "" };
191
+ }
192
+
193
+ export function parseBgCommandArgs(args: string): { name?: string; command: string; isAgent: boolean } {
194
+ let input = args.trim();
195
+ let name: string | undefined;
196
+ let isAgent = false;
197
+
198
+ while (input) {
199
+ let consumed = false;
200
+ for (const prefix of ["--name=", "-n="]) {
201
+ if (input.startsWith(prefix)) {
202
+ const parsed = parseNameValueAndRest(input.slice(prefix.length));
203
+ if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
204
+ name = normalizeTaskName(parsed.value);
205
+ input = parsed.rest;
206
+ consumed = true;
207
+ break;
208
+ }
209
+ }
210
+ if (consumed) continue;
211
+
212
+ for (const prefix of ["--name", "-n"]) {
213
+ if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
214
+ const parsed = parseNameValueAndRest(input.slice(prefix.length));
215
+ if (!parsed) throw new Error(`${prefix} requires a task name`);
216
+ name = normalizeTaskName(parsed.value);
217
+ input = parsed.rest;
218
+ consumed = true;
219
+ break;
220
+ }
221
+ }
222
+ if (consumed) continue;
223
+
224
+ for (const flag of ["--agent", "--llm-agent"]) {
225
+ if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
226
+ isAgent = true;
227
+ input = input.slice(flag.length).trimStart();
228
+ consumed = true;
229
+ break;
230
+ }
231
+ }
232
+ if (consumed) continue;
233
+
234
+ for (const flag of ["--script", "--no-agent"]) {
235
+ if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
236
+ isAgent = false;
237
+ input = input.slice(flag.length).trimStart();
238
+ consumed = true;
239
+ break;
240
+ }
241
+ }
242
+ if (consumed) continue;
243
+
244
+ if (input === "--") {
245
+ input = "";
246
+ break;
247
+ }
248
+ if (input.startsWith("-- ")) {
249
+ input = input.slice(3).trimStart();
250
+ break;
251
+ }
252
+ break;
253
+ }
254
+
255
+ return name ? { name, command: input, isAgent } : { command: input, isAgent };
256
+ }
257
+
258
+ export function formatDuration(ms: number): string {
259
+ if (ms < 1000) return `${ms}ms`;
260
+ const seconds = Math.floor(ms / 1000);
261
+ if (seconds < 60) return `${seconds}s`;
262
+ const minutes = Math.floor(seconds / 60);
263
+ const remSeconds = seconds % 60;
264
+ if (minutes < 60) return `${minutes}m${remSeconds ? `${remSeconds}s` : ""}`;
265
+ const hours = Math.floor(minutes / 60);
266
+ const remMinutes = minutes % 60;
267
+ return `${hours}h${remMinutes ? `${remMinutes}m` : ""}`;
268
+ }
269
+
270
+ export function formatCompactNumber(count: number): string {
271
+ const normalized = Math.max(0, Math.floor(count));
272
+ if (normalized < 1000) return normalized.toString();
273
+ if (normalized < 10000) return `${(normalized / 1000).toFixed(1)}k`;
274
+ if (normalized < 1000000) return `${Math.round(normalized / 1000)}k`;
275
+ if (normalized < 10000000) return `${(normalized / 1000000).toFixed(1)}M`;
276
+ return `${Math.round(normalized / 1000000)}M`;
277
+ }
278
+
279
+ export function formatContextUsageSummary(usage?: TaskContextUsage): string | undefined {
280
+ if (!usage || !usage.contextWindow) return undefined;
281
+ const window = formatCompactNumber(usage.contextWindow);
282
+ if (usage.percent === null || usage.tokens === null) return `ctx=?/${window}`;
283
+ return `ctx=${usage.percent.toFixed(1)}%/${window}`;
284
+ }
285
+
286
+ export function formatTokenUsageSummary(usage?: TaskTokenUsage): string | undefined {
287
+ if (!usage || usage.totalTokens <= 0) return undefined;
288
+ return `tokens=${formatCompactNumber(usage.totalTokens)}`;
289
+ }
290
+
291
+ export function formatToolUsageSummary(usage?: TaskToolUsage): string | undefined {
292
+ if (!usage || (usage.total <= 0 && usage.failed <= 0)) return undefined;
293
+ const failed = usage.failed > 0 ? ` failed=${usage.failed}` : "";
294
+ return `tools=${usage.total}${failed}`;
295
+ }
296
+
297
+ export function formatModelSummary(model?: string): string | undefined {
298
+ if (!model) return undefined;
299
+ return `model=${model}`;
300
+ }
301
+
302
+ export function shellQuote(value: string): string {
303
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
304
+ }
305
+
306
+ export function shellInvocation(
307
+ command: string,
308
+ platform: NodeJS.Platform = process.platform,
309
+ env: NodeJS.ProcessEnv = process.env,
310
+ ): { shell: string; args: string[] } {
311
+ if (platform === "win32") {
312
+ return { shell: env["ComSpec"] || "cmd.exe", args: ["/d", "/s", "/c", command] };
313
+ }
314
+ return { shell: env["SHELL"] || "/bin/sh", args: ["-c", command] };
315
+ }
316
+
317
+ export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
318
+ const raw = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback;
319
+ return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
320
+ }
321
+
322
+ export function snapshot(task: BgTask): BgTaskSnapshot {
323
+ return {
324
+ id: task.id,
325
+ name: taskDisplayName(task),
326
+ command: task.command,
327
+ description: task.description,
328
+ status: task.status,
329
+ outputPath: task.outputPath,
330
+ cwd: task.cwd,
331
+ startTime: task.startTime,
332
+ endTime: task.endTime,
333
+ exitCode: task.exitCode,
334
+ signal: task.signal,
335
+ pid: task.pid,
336
+ bytesWritten: task.bytesWritten,
337
+ isAgent: task.isAgent,
338
+ error: task.error,
339
+ notified: task.notified,
340
+ notifyOnCompletion: task.notifyOnCompletion,
341
+ triggerOnCompletion: task.triggerOnCompletion,
342
+ timeoutSeconds: task.timeoutSeconds,
343
+ contextUsage: task.contextUsage,
344
+ tokenUsage: task.tokenUsage,
345
+ toolUsage: task.toolUsage,
346
+ model: task.model,
347
+ };
348
+ }
349
+
350
+ export function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
351
+ if (tasks.length === 0) return "No background tasks in this Pi extension runtime.";
352
+ return tasks.map((task) => {
353
+ const statusIcon = task.status === "running" ? "▶" : task.status === "completed" ? "✓" : task.status === "killed" ? "■" : "✗";
354
+ const age = formatDuration((task.endTime ?? now) - task.startTime);
355
+ const code = task.exitCode !== undefined ? ` exit=${task.exitCode}` : "";
356
+ const pid = task.pid ? ` pid=${task.pid}` : "";
357
+ const error = task.error ? ` error=${truncateChars(task.error, 80)}` : "";
358
+ const telemetry = [
359
+ formatContextUsageSummary(task.contextUsage),
360
+ formatModelSummary(task.model),
361
+ formatTokenUsageSummary(task.tokenUsage),
362
+ formatToolUsageSummary(task.toolUsage),
363
+ ].filter(Boolean).join(" ");
364
+ const telemetryText = telemetry ? ` ${telemetry}` : "";
365
+ return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid}${telemetryText} — ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
366
+ }).join("\n");
367
+ }
368
+
369
+ export async function boundedRead(
370
+ filePath: string,
371
+ maxBytes: number,
372
+ tail: boolean,
373
+ ): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
374
+ const stats = statSync(filePath);
375
+ const totalBytes = stats.size;
376
+ const bytesToRead = Math.min(totalBytes, maxBytes);
377
+ if (bytesToRead === 0) return { content: "", truncated: false, bytesRead: 0, totalBytes };
378
+
379
+ const file = await open(filePath, "r");
380
+ try {
381
+ const buffer = Buffer.alloc(bytesToRead);
382
+ const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
383
+ const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
384
+ return {
385
+ content: buffer.subarray(0, bytesRead).toString("utf8"),
386
+ truncated: totalBytes > bytesRead,
387
+ bytesRead,
388
+ totalBytes,
389
+ };
390
+ } finally {
391
+ await file.close();
392
+ }
393
+ }
394
+
395
+ export function escapeXml(value: string): string {
396
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
397
+ }
398
+
399
+ export const UPDATE_COMMAND = "/bg-update";
400
+
401
+ type ParsedSemver = {
402
+ major: number;
403
+ minor: number;
404
+ patch: number;
405
+ prerelease: string[];
406
+ };
407
+
408
+ const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
409
+
410
+ export function parseSemver(value: string): ParsedSemver | undefined {
411
+ if (typeof value !== "string") return undefined;
412
+ const match = value.trim().match(SEMVER_PATTERN);
413
+ if (!match) return undefined;
414
+ const major = Number(match[1]);
415
+ const minor = Number(match[2]);
416
+ const patch = Number(match[3]);
417
+ if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) return undefined;
418
+ const prerelease = match[4] ? match[4].split(".") : [];
419
+ return { major, minor, patch, prerelease };
420
+ }
421
+
422
+ function comparePrerelease(a: string[], b: string[]): number {
423
+ if (a.length === 0 && b.length === 0) return 0;
424
+ // A version without prerelease identifiers outranks the same core with prerelease identifiers.
425
+ if (a.length === 0) return 1;
426
+ if (b.length === 0) return -1;
427
+ const shared = Math.min(a.length, b.length);
428
+ for (let i = 0; i < shared; i++) {
429
+ const idA = a[i];
430
+ const idB = b[i];
431
+ if (idA === undefined || idB === undefined) break;
432
+ if (idA === idB) continue;
433
+ const numericA = /^\d+$/.test(idA);
434
+ const numericB = /^\d+$/.test(idB);
435
+ if (numericA && numericB) {
436
+ const diff = Number(idA) - Number(idB);
437
+ if (diff !== 0) return diff < 0 ? -1 : 1;
438
+ continue;
439
+ }
440
+ // Numeric identifiers always have lower precedence than non-numeric identifiers.
441
+ if (numericA) return -1;
442
+ if (numericB) return 1;
443
+ return idA < idB ? -1 : 1;
444
+ }
445
+ if (a.length === b.length) return 0;
446
+ return a.length < b.length ? -1 : 1;
447
+ }
448
+
449
+ /** Compare two semver strings. Returns -1/0/1, or undefined when either side is not valid semver. */
450
+ export function compareSemver(a: string, b: string): number | undefined {
451
+ const left = parseSemver(a);
452
+ const right = parseSemver(b);
453
+ if (!left || !right) return undefined;
454
+ if (left.major !== right.major) return left.major < right.major ? -1 : 1;
455
+ if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
456
+ if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
457
+ return comparePrerelease(left.prerelease, right.prerelease);
458
+ }
459
+
460
+ export function isNewerVersion(latest: string, current: string): boolean {
461
+ return compareSemver(latest, current) === 1;
462
+ }
463
+
464
+ /** Footer segment shown only when a newer published version exists; undefined otherwise. */
465
+ export function formatUpdateSegment(latest: string | undefined, current: string): string | undefined {
466
+ if (!latest) return undefined;
467
+ if (!isNewerVersion(latest, current)) return undefined;
468
+ return `\u2b06 v${latest} ${UPDATE_COMMAND}`;
469
+ }