pi-background-tasks 0.6.0 → 0.7.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.
- package/PUBLISHING.md +15 -15
- package/README.md +73 -5
- package/TESTING.md +28 -12
- package/TEST_PLAN.md +16 -7
- package/extensions/background-tasks.ts +1 -1
- package/package.json +16 -10
- package/src/core/attested-pi-run.ts +619 -0
- package/src/core/common.ts +539 -432
- package/src/core/extension-api.ts +548 -0
- package/src/core/fusion/artifacts.ts +443 -0
- package/src/core/fusion/config.ts +371 -0
- package/src/core/fusion/context.ts +179 -0
- package/src/core/fusion/evaluation.ts +362 -0
- package/src/core/fusion/orchestrator.ts +593 -0
- package/src/core/fusion/pi-child.ts +816 -0
- package/src/core/fusion/prompts.ts +155 -0
- package/src/core/fusion/types.ts +288 -0
- package/src/core/registry.ts +1351 -786
- package/src/core/update-check.ts +69 -63
- package/src/extension.ts +863 -524
- package/src/fusion-extension.ts +616 -0
- package/src/testing/normalize.ts +22 -3
- package/src/ui/background-tasks-manager.ts +703 -613
- package/src/ui/fusion-model-selector.ts +322 -0
package/src/core/common.ts
CHANGED
|
@@ -1,306 +1,372 @@
|
|
|
1
|
-
import { statSync } from
|
|
2
|
-
import { open } from
|
|
3
|
-
import { DEFAULT_MAX_BYTES } from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
1
|
+
import { statSync, type WriteStream } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
import type { BackgroundTaskChildProcess } from './registry.js';
|
|
5
|
+
|
|
6
|
+
export const TASK_STATUS_VALUES = ['running', 'completed', 'failed', 'killed'] as const;
|
|
7
|
+
export const TERMINAL_TASK_STATUS_VALUES = ['completed', 'failed', 'killed'] as const;
|
|
8
|
+
|
|
9
|
+
export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
|
|
10
|
+
export type TerminalTaskStatus = (typeof TERMINAL_TASK_STATUS_VALUES)[number];
|
|
11
|
+
export type KillKind = 'user' | 'timeout' | 'output_cap' | 'shutdown';
|
|
12
|
+
|
|
13
|
+
export type JsonObject = Readonly<Record<PropertyKey, unknown>>;
|
|
14
|
+
|
|
15
|
+
export interface TaskContextUsage {
|
|
16
|
+
tokens: number | null;
|
|
17
|
+
contextWindow: number;
|
|
18
|
+
percent: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TaskTokenUsage {
|
|
22
|
+
input: number;
|
|
23
|
+
output: number;
|
|
24
|
+
cacheRead: number;
|
|
25
|
+
cacheWrite: number;
|
|
26
|
+
totalTokens: number;
|
|
27
|
+
costTotal?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TaskToolUsage {
|
|
31
|
+
total: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
byName: Record<string, number>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BgTaskSnapshot {
|
|
37
|
+
id: string;
|
|
38
|
+
name?: string | undefined;
|
|
39
|
+
command: string;
|
|
40
|
+
description?: string | undefined;
|
|
41
|
+
status: TaskStatus;
|
|
42
|
+
outputPath: string;
|
|
43
|
+
cwd: string;
|
|
44
|
+
startTime: number;
|
|
45
|
+
endTime?: number | undefined;
|
|
46
|
+
exitCode?: number | null | undefined;
|
|
47
|
+
signal?: string | null | undefined;
|
|
48
|
+
pid?: number | undefined;
|
|
49
|
+
bytesWritten: number;
|
|
50
|
+
isAgent: boolean;
|
|
51
|
+
error?: string | undefined;
|
|
52
|
+
notified: boolean;
|
|
53
|
+
notifyOnCompletion: boolean;
|
|
54
|
+
triggerOnCompletion: boolean;
|
|
55
|
+
timeoutSeconds?: number | undefined;
|
|
56
|
+
contextUsage?: TaskContextUsage | undefined;
|
|
57
|
+
tokenUsage?: TaskTokenUsage | undefined;
|
|
58
|
+
toolUsage?: TaskToolUsage | undefined;
|
|
59
|
+
model?: string | undefined;
|
|
60
|
+
attestationPath?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AttestedPiTaskFiles {
|
|
64
|
+
eventsPath: string;
|
|
65
|
+
stderrPath: string;
|
|
66
|
+
wrapperPath: string;
|
|
67
|
+
attestationPath: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AttestedPiTaskSnapshot extends BgTaskSnapshot {
|
|
71
|
+
attestedPi?: AttestedPiTaskFiles | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BgTask extends Omit<BgTaskSnapshot, 'name'> {
|
|
75
|
+
name: string;
|
|
76
|
+
outputAbsPath: string;
|
|
77
|
+
metadataAbsPath: string;
|
|
78
|
+
eventsAbsPath?: string | undefined;
|
|
79
|
+
stderrAbsPath?: string | undefined;
|
|
80
|
+
wrapperAbsPath?: string | undefined;
|
|
81
|
+
attestationAbsPath?: string | undefined;
|
|
82
|
+
child?: BackgroundTaskChildProcess | undefined;
|
|
83
|
+
stream?: WriteStream | undefined;
|
|
84
|
+
timeoutHandle?: NodeJS.Timeout | undefined;
|
|
85
|
+
killKind?: KillKind | undefined;
|
|
86
|
+
killSignalSent?: boolean | undefined;
|
|
87
|
+
capExceeded?: boolean | undefined;
|
|
88
|
+
finalized?: boolean | undefined;
|
|
89
|
+
terminalPublished?: boolean | undefined;
|
|
90
|
+
terminalPublishInFlight?: boolean | undefined;
|
|
91
|
+
terminalPublishRetryHandle?: NodeJS.Timeout | undefined;
|
|
92
|
+
/** Optional protocol barrier used by EventBus run requests so early child exits cannot publish before the run response is observable. */
|
|
93
|
+
terminalPublicationGate?: Promise<void> | undefined;
|
|
94
|
+
contextUsageBuffer?: string | undefined;
|
|
95
|
+
/** True when this task launched a telemetry-wrapped Pi agent; its stdout carries control lines, not raw output. */
|
|
96
|
+
telemetryWrapped?: boolean | undefined;
|
|
97
|
+
/** Partial trailing stdout line held between chunks while reconstructing wrapped-agent control lines. */
|
|
98
|
+
agentStdoutBuffer?: string | undefined;
|
|
99
|
+
attestationPath?: string | undefined;
|
|
100
|
+
attestedPi?: AttestedPiTaskFiles | undefined;
|
|
101
|
+
metadataWriteChain?: Promise<void> | undefined;
|
|
102
|
+
waiters: Array<() => void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface BgRunDetails {
|
|
106
|
+
task: BgTaskSnapshot;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface BgStatusDetails {
|
|
110
|
+
tasks: BgTaskSnapshot[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface BgLogsDetails {
|
|
114
|
+
task: BgTaskSnapshot;
|
|
115
|
+
path: string;
|
|
116
|
+
bytesRead: number;
|
|
117
|
+
truncated: boolean;
|
|
118
|
+
tail: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface BgKillDetails {
|
|
122
|
+
task: BgTaskSnapshot;
|
|
123
|
+
message: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface StartTaskOptions {
|
|
127
|
+
name?: string | undefined;
|
|
128
|
+
description?: string | undefined;
|
|
129
|
+
isAgent?: boolean | undefined;
|
|
130
|
+
timeoutSeconds?: number | undefined;
|
|
131
|
+
notifyOnCompletion?: boolean | undefined;
|
|
132
|
+
triggerOnCompletion?: boolean | undefined;
|
|
133
|
+
/** @internal EventBus protocol barrier; callers should not set this outside the extension service. */
|
|
134
|
+
terminalPublicationGate?: Promise<void> | undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface StartAttestedPiTaskOptions {
|
|
138
|
+
name: string;
|
|
139
|
+
provider: string;
|
|
140
|
+
model: string;
|
|
141
|
+
prompt: string;
|
|
142
|
+
reportPath: string;
|
|
143
|
+
extraPiArgs?: string[] | undefined;
|
|
144
|
+
thinking?: string | undefined;
|
|
145
|
+
timeoutSeconds?: number | undefined;
|
|
146
|
+
}
|
|
103
147
|
|
|
104
148
|
export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
105
149
|
export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
106
150
|
export const COMMAND_PREVIEW_CHARS = 90;
|
|
151
|
+
const parseJsonValue: (text: string) => unknown = globalThis.JSON.parse;
|
|
152
|
+
|
|
153
|
+
export function isJsonObject(value: unknown): value is JsonObject {
|
|
154
|
+
return typeof value === 'object' && value !== null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function parseJsonText(text: string): unknown {
|
|
158
|
+
return parseJsonValue(text);
|
|
159
|
+
}
|
|
107
160
|
|
|
108
161
|
export function sanitizePathSegment(value: string): string {
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
163
|
+
return sanitized || 'session';
|
|
111
164
|
}
|
|
112
165
|
|
|
113
166
|
export function stripMatchingQuotes(value: string): string {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
const trimmed = value.trim();
|
|
168
|
+
if (trimmed.length >= 2) {
|
|
169
|
+
const first = trimmed[0];
|
|
170
|
+
const last = trimmed[trimmed.length - 1];
|
|
171
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
172
|
+
return trimmed.slice(1, -1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return trimmed;
|
|
123
176
|
}
|
|
124
177
|
|
|
125
178
|
export function compactWhitespace(value: string): string {
|
|
126
|
-
|
|
179
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
127
180
|
}
|
|
128
181
|
|
|
129
182
|
export function truncateChars(value: string, maxChars: number): string {
|
|
130
|
-
|
|
131
|
-
|
|
183
|
+
if (value.length <= maxChars) return value;
|
|
184
|
+
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
export function normalizeTaskName(value: unknown): string | undefined {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
188
|
+
if (typeof value !== 'string') return undefined;
|
|
189
|
+
const normalized = compactWhitespace(stripMatchingQuotes(value));
|
|
190
|
+
if (!normalized) return undefined;
|
|
191
|
+
return truncateChars(normalized, 80);
|
|
139
192
|
}
|
|
140
193
|
|
|
141
194
|
export function deriveTaskNameFromCommand(command: string): string {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
const normalized = compactWhitespace(stripMatchingQuotes(command));
|
|
196
|
+
if (!normalized) return 'Background task';
|
|
197
|
+
|
|
198
|
+
const packageScript = /^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/.exec(normalized);
|
|
199
|
+
if (packageScript) {
|
|
200
|
+
const runner = packageScript[1] ?? 'npm';
|
|
201
|
+
const run = packageScript[2] !== undefined ? ' run' : '';
|
|
202
|
+
const script = packageScript[3] ?? '';
|
|
203
|
+
return truncateChars(`${runner}${run} ${script}`, 48);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const words = normalized.split(/\s+/).slice(0, 5).join(' ');
|
|
207
|
+
return truncateChars(words.length > 0 ? words : normalized, 48);
|
|
155
208
|
}
|
|
156
209
|
|
|
157
|
-
export function taskDisplayName(task: {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
210
|
+
export function taskDisplayName(task: {
|
|
211
|
+
name?: string | undefined;
|
|
212
|
+
description?: string | undefined;
|
|
213
|
+
command?: string | undefined;
|
|
214
|
+
id?: string | undefined;
|
|
215
|
+
}): string {
|
|
216
|
+
const commandName =
|
|
217
|
+
task.command && task.command.length > 0 ? deriveTaskNameFromCommand(task.command) : undefined;
|
|
218
|
+
return (
|
|
219
|
+
normalizeTaskName(task.name) ??
|
|
220
|
+
normalizeTaskName(task.description) ??
|
|
221
|
+
commandName ??
|
|
222
|
+
task.id ??
|
|
223
|
+
'Background task'
|
|
224
|
+
);
|
|
163
225
|
}
|
|
164
226
|
|
|
165
227
|
function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export function parseBgCommandArgs(args: string): {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
228
|
+
const input = valueAndRest.trimStart();
|
|
229
|
+
if (!input) return undefined;
|
|
230
|
+
const quote = input[0];
|
|
231
|
+
if (quote === '"' || quote === "'") {
|
|
232
|
+
let escaped = false;
|
|
233
|
+
let value = '';
|
|
234
|
+
for (let i = 1; i < input.length; i++) {
|
|
235
|
+
const char = input.charAt(i);
|
|
236
|
+
if (escaped) {
|
|
237
|
+
value += char;
|
|
238
|
+
escaped = false;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (char === '\\') {
|
|
242
|
+
escaped = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (char === quote) {
|
|
246
|
+
return { value, rest: input.slice(i + 1).trimStart() };
|
|
247
|
+
}
|
|
248
|
+
value += char;
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(input);
|
|
253
|
+
if (!match) return undefined;
|
|
254
|
+
const parsedValue = match[1];
|
|
255
|
+
if (parsedValue === undefined) return undefined;
|
|
256
|
+
return { value: parsedValue, rest: match[2]?.trimStart() ?? '' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function parseBgCommandArgs(args: string): {
|
|
260
|
+
name?: string;
|
|
261
|
+
command: string;
|
|
262
|
+
isAgent: boolean;
|
|
263
|
+
} {
|
|
264
|
+
let input = args.trim();
|
|
265
|
+
let name: string | undefined;
|
|
266
|
+
let isAgent = false;
|
|
267
|
+
|
|
268
|
+
while (input) {
|
|
269
|
+
let consumed = false;
|
|
270
|
+
for (const prefix of ['--name=', '-n=']) {
|
|
271
|
+
if (input.startsWith(prefix)) {
|
|
272
|
+
const parsed = parseNameValueAndRest(input.slice(prefix.length));
|
|
273
|
+
if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
|
|
274
|
+
name = normalizeTaskName(parsed.value);
|
|
275
|
+
input = parsed.rest;
|
|
276
|
+
consumed = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (consumed) continue;
|
|
281
|
+
|
|
282
|
+
for (const prefix of ['--name', '-n']) {
|
|
283
|
+
if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
|
|
284
|
+
const parsed = parseNameValueAndRest(input.slice(prefix.length));
|
|
285
|
+
if (!parsed) throw new Error(`${prefix} requires a task name`);
|
|
286
|
+
name = normalizeTaskName(parsed.value);
|
|
287
|
+
input = parsed.rest;
|
|
288
|
+
consumed = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (consumed) continue;
|
|
293
|
+
|
|
294
|
+
for (const flag of ['--agent', '--llm-agent']) {
|
|
295
|
+
if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
|
|
296
|
+
isAgent = true;
|
|
297
|
+
input = input.slice(flag.length).trimStart();
|
|
298
|
+
consumed = true;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (consumed) continue;
|
|
303
|
+
|
|
304
|
+
for (const flag of ['--script', '--no-agent']) {
|
|
305
|
+
if (input === flag || input.startsWith(`${flag} `) || input.startsWith(`${flag}\t`)) {
|
|
306
|
+
isAgent = false;
|
|
307
|
+
input = input.slice(flag.length).trimStart();
|
|
308
|
+
consumed = true;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (consumed) continue;
|
|
313
|
+
|
|
314
|
+
if (input === '--') {
|
|
315
|
+
input = '';
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (input.startsWith('-- ')) {
|
|
319
|
+
input = input.slice(3).trimStart();
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return name ? { name, command: input, isAgent } : { command: input, isAgent };
|
|
260
326
|
}
|
|
261
327
|
|
|
262
328
|
export function formatDuration(ms: number): string {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
329
|
+
if (ms < 1000) return `${String(ms)}ms`;
|
|
330
|
+
const seconds = Math.floor(ms / 1000);
|
|
331
|
+
if (seconds < 60) return `${String(seconds)}s`;
|
|
332
|
+
const minutes = Math.floor(seconds / 60);
|
|
333
|
+
const remSeconds = seconds % 60;
|
|
334
|
+
if (minutes < 60) return `${String(minutes)}m${remSeconds > 0 ? `${String(remSeconds)}s` : ''}`;
|
|
335
|
+
const hours = Math.floor(minutes / 60);
|
|
336
|
+
const remMinutes = minutes % 60;
|
|
337
|
+
return `${String(hours)}h${remMinutes > 0 ? `${String(remMinutes)}m` : ''}`;
|
|
272
338
|
}
|
|
273
339
|
|
|
274
340
|
export function formatCompactNumber(count: number): string {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
341
|
+
const normalized = Math.max(0, Math.floor(count));
|
|
342
|
+
if (normalized < 1000) return normalized.toString();
|
|
343
|
+
if (normalized < 10000) return `${(normalized / 1000).toFixed(1)}k`;
|
|
344
|
+
if (normalized < 1000000) return `${String(Math.round(normalized / 1000))}k`;
|
|
345
|
+
if (normalized < 10000000) return `${(normalized / 1000000).toFixed(1)}M`;
|
|
346
|
+
return `${String(Math.round(normalized / 1000000))}M`;
|
|
281
347
|
}
|
|
282
348
|
|
|
283
349
|
export function formatContextUsageSummary(usage?: TaskContextUsage): string | undefined {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
350
|
+
if (usage?.contextWindow === undefined || usage.contextWindow <= 0) return undefined;
|
|
351
|
+
const window = formatCompactNumber(usage.contextWindow);
|
|
352
|
+
if (usage.percent === null || usage.tokens === null) return `ctx=?/${window}`;
|
|
353
|
+
return `ctx=${usage.percent.toFixed(1)}%/${window}`;
|
|
288
354
|
}
|
|
289
355
|
|
|
290
356
|
export function formatTokenUsageSummary(usage?: TaskTokenUsage): string | undefined {
|
|
291
|
-
|
|
292
|
-
|
|
357
|
+
if (!usage || usage.totalTokens <= 0) return undefined;
|
|
358
|
+
return `tokens=${formatCompactNumber(usage.totalTokens)}`;
|
|
293
359
|
}
|
|
294
360
|
|
|
295
361
|
export function formatToolUsageSummary(usage?: TaskToolUsage): string | undefined {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
362
|
+
if (!usage || (usage.total <= 0 && usage.failed <= 0)) return undefined;
|
|
363
|
+
const failed = usage.failed > 0 ? ` failed=${String(usage.failed)}` : '';
|
|
364
|
+
return `tools=${String(usage.total)}${failed}`;
|
|
299
365
|
}
|
|
300
366
|
|
|
301
367
|
export function formatModelSummary(model?: string): string | undefined {
|
|
302
|
-
|
|
303
|
-
|
|
368
|
+
if (!model) return undefined;
|
|
369
|
+
return `model=${model}`;
|
|
304
370
|
}
|
|
305
371
|
|
|
306
372
|
/**
|
|
@@ -312,45 +378,58 @@ export function formatModelSummary(model?: string): string | undefined {
|
|
|
312
378
|
* file instead of leaking raw telemetry JSON. Both the parser and the formatter
|
|
313
379
|
* are pure so the visible transcript is fully unit-testable.
|
|
314
380
|
*/
|
|
315
|
-
export const AGENT_ACTIVITY_TYPE =
|
|
381
|
+
export const AGENT_ACTIVITY_TYPE = 'background-task-activity';
|
|
316
382
|
const AGENT_ACTIVITY_DETAIL_MAX = 80;
|
|
317
383
|
|
|
318
384
|
export type AgentActivity =
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
385
|
+
| { kind: 'assistant_text'; text: string }
|
|
386
|
+
| { kind: 'reasoning'; text: string }
|
|
387
|
+
| { kind: 'tool_start'; tool: string; argsSummary: string }
|
|
388
|
+
| { kind: 'tool_end'; tool: string; isError: boolean; error?: string };
|
|
389
|
+
|
|
390
|
+
interface AgentActivityPayload extends JsonObject {
|
|
391
|
+
readonly type?: unknown;
|
|
392
|
+
readonly kind?: unknown;
|
|
393
|
+
readonly text?: unknown;
|
|
394
|
+
readonly tool?: unknown;
|
|
395
|
+
readonly argsSummary?: unknown;
|
|
396
|
+
readonly isError?: unknown;
|
|
397
|
+
readonly error?: unknown;
|
|
398
|
+
}
|
|
323
399
|
|
|
324
|
-
function readActivityString(
|
|
325
|
-
|
|
326
|
-
|
|
400
|
+
function readActivityString(
|
|
401
|
+
record: AgentActivityPayload,
|
|
402
|
+
key: 'text' | 'tool' | 'argsSummary' | 'error',
|
|
403
|
+
): string | undefined {
|
|
404
|
+
const value = record[key];
|
|
405
|
+
return typeof value === 'string' ? value : undefined;
|
|
327
406
|
}
|
|
328
407
|
|
|
329
408
|
/** Narrow a parsed `background-task-activity` control payload into a typed {@link AgentActivity}. */
|
|
330
409
|
export function parseAgentActivity(payload: unknown): AgentActivity | undefined {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
410
|
+
if (!isJsonObject(payload)) return undefined;
|
|
411
|
+
const record: AgentActivityPayload = payload;
|
|
412
|
+
if (record.type !== AGENT_ACTIVITY_TYPE) return undefined;
|
|
413
|
+
const kind = record.kind;
|
|
414
|
+
if (kind === 'assistant_text' || kind === 'reasoning') {
|
|
415
|
+
const text = readActivityString(record, 'text');
|
|
416
|
+
if (text === undefined) return undefined;
|
|
417
|
+
return { kind, text };
|
|
418
|
+
}
|
|
419
|
+
if (kind === 'tool_start') {
|
|
420
|
+
const tool = readActivityString(record, 'tool');
|
|
421
|
+
if (!tool) return undefined;
|
|
422
|
+
return { kind, tool, argsSummary: readActivityString(record, 'argsSummary') ?? '' };
|
|
423
|
+
}
|
|
424
|
+
if (kind === 'tool_end') {
|
|
425
|
+
const tool = readActivityString(record, 'tool');
|
|
426
|
+
if (!tool) return undefined;
|
|
427
|
+
const activity: AgentActivity = { kind, tool, isError: record.isError === true };
|
|
428
|
+
const error = readActivityString(record, 'error');
|
|
429
|
+
if (error !== undefined && error.trim().length > 0) activity.error = error;
|
|
430
|
+
return activity;
|
|
431
|
+
}
|
|
432
|
+
return undefined;
|
|
354
433
|
}
|
|
355
434
|
|
|
356
435
|
/**
|
|
@@ -360,189 +439,217 @@ export function parseAgentActivity(payload: unknown): AgentActivity | undefined
|
|
|
360
439
|
* line already announced the call, and the next line implies completion.
|
|
361
440
|
*/
|
|
362
441
|
export function formatAgentActivityLine(activity: AgentActivity): string | undefined {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
442
|
+
if (activity.kind === 'assistant_text') {
|
|
443
|
+
const text = activity.text.replace(/\s+$/u, '');
|
|
444
|
+
return text.trim().length > 0 ? text : undefined;
|
|
445
|
+
}
|
|
446
|
+
if (activity.kind === 'reasoning') {
|
|
447
|
+
const text = activity.text.replace(/\s+$/u, '');
|
|
448
|
+
return text.trim().length > 0 ? `\u2026 ${text}` : undefined;
|
|
449
|
+
}
|
|
450
|
+
if (activity.kind === 'tool_start') {
|
|
451
|
+
const summary = compactWhitespace(activity.argsSummary);
|
|
452
|
+
const suffix =
|
|
453
|
+
summary.length > 0 ? ` ${truncateChars(summary, AGENT_ACTIVITY_DETAIL_MAX)}` : '';
|
|
454
|
+
return `\u2192 ${activity.tool}${suffix}`;
|
|
455
|
+
}
|
|
456
|
+
if (!activity.isError) return undefined;
|
|
457
|
+
const detail = activity.error
|
|
458
|
+
? `: ${truncateChars(compactWhitespace(activity.error), AGENT_ACTIVITY_DETAIL_MAX)}`
|
|
459
|
+
: '';
|
|
460
|
+
return `\u2717 ${activity.tool} failed${detail}`;
|
|
379
461
|
}
|
|
380
462
|
|
|
381
463
|
export function shellQuote(value: string): string {
|
|
382
|
-
|
|
464
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
383
465
|
}
|
|
384
466
|
|
|
385
467
|
export function shellInvocation(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
468
|
+
command: string,
|
|
469
|
+
platform: NodeJS.Platform = process.platform,
|
|
470
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
389
471
|
): { shell: string; args: string[] } {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
472
|
+
if (platform === 'win32') {
|
|
473
|
+
const comSpec = env['ComSpec'];
|
|
474
|
+
return {
|
|
475
|
+
shell: comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe',
|
|
476
|
+
args: ['/d', '/s', '/c', command],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const shell = env['SHELL'];
|
|
480
|
+
return { shell: shell && shell.length > 0 ? shell : '/bin/sh', args: ['-c', command] };
|
|
394
481
|
}
|
|
395
482
|
|
|
396
483
|
export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
|
|
397
|
-
|
|
398
|
-
|
|
484
|
+
const raw = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
485
|
+
return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
|
|
399
486
|
}
|
|
400
487
|
|
|
401
488
|
export function snapshot(task: BgTask): BgTaskSnapshot {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
489
|
+
return {
|
|
490
|
+
id: task.id,
|
|
491
|
+
name: taskDisplayName(task),
|
|
492
|
+
command: task.command,
|
|
493
|
+
description: task.description,
|
|
494
|
+
status: task.status,
|
|
495
|
+
outputPath: task.outputPath,
|
|
496
|
+
cwd: task.cwd,
|
|
497
|
+
startTime: task.startTime,
|
|
498
|
+
endTime: task.endTime,
|
|
499
|
+
exitCode: task.exitCode,
|
|
500
|
+
signal: task.signal,
|
|
501
|
+
pid: task.pid,
|
|
502
|
+
bytesWritten: task.bytesWritten,
|
|
503
|
+
isAgent: task.isAgent,
|
|
504
|
+
error: task.error,
|
|
505
|
+
notified: task.notified,
|
|
506
|
+
notifyOnCompletion: task.notifyOnCompletion,
|
|
507
|
+
triggerOnCompletion: task.triggerOnCompletion,
|
|
508
|
+
timeoutSeconds: task.timeoutSeconds,
|
|
509
|
+
contextUsage: task.contextUsage,
|
|
510
|
+
tokenUsage: task.tokenUsage,
|
|
511
|
+
toolUsage: task.toolUsage,
|
|
512
|
+
model: task.model,
|
|
513
|
+
attestationPath: task.attestationPath,
|
|
514
|
+
};
|
|
427
515
|
}
|
|
428
516
|
|
|
429
517
|
export function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
518
|
+
if (tasks.length === 0) return 'No background tasks in this Pi extension runtime.';
|
|
519
|
+
return tasks
|
|
520
|
+
.map((task) => {
|
|
521
|
+
const statusIcon =
|
|
522
|
+
task.status === 'running'
|
|
523
|
+
? '▶'
|
|
524
|
+
: task.status === 'completed'
|
|
525
|
+
? '✓'
|
|
526
|
+
: task.status === 'killed'
|
|
527
|
+
? '■'
|
|
528
|
+
: '✗';
|
|
529
|
+
const age = formatDuration((task.endTime ?? now) - task.startTime);
|
|
530
|
+
const code = task.exitCode !== undefined ? ` exit=${String(task.exitCode)}` : '';
|
|
531
|
+
const pid = task.pid !== undefined ? ` pid=${String(task.pid)}` : '';
|
|
532
|
+
const error = task.error ? ` error=${truncateChars(task.error, 80)}` : '';
|
|
533
|
+
const telemetry = [
|
|
534
|
+
formatContextUsageSummary(task.contextUsage),
|
|
535
|
+
formatModelSummary(task.model),
|
|
536
|
+
formatTokenUsageSummary(task.tokenUsage),
|
|
537
|
+
formatToolUsageSummary(task.toolUsage),
|
|
538
|
+
]
|
|
539
|
+
.filter(Boolean)
|
|
540
|
+
.join(' ');
|
|
541
|
+
const telemetryText = telemetry ? ` ${telemetry}` : '';
|
|
542
|
+
return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid}${telemetryText} — ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
|
|
543
|
+
})
|
|
544
|
+
.join('\n');
|
|
446
545
|
}
|
|
447
546
|
|
|
448
547
|
export async function boundedRead(
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
548
|
+
filePath: string,
|
|
549
|
+
maxBytes: number,
|
|
550
|
+
tail: boolean,
|
|
452
551
|
): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
552
|
+
const stats = statSync(filePath);
|
|
553
|
+
const totalBytes = stats.size;
|
|
554
|
+
const bytesToRead = Math.min(totalBytes, maxBytes);
|
|
555
|
+
if (bytesToRead === 0) return { content: '', truncated: false, bytesRead: 0, totalBytes };
|
|
556
|
+
|
|
557
|
+
const file = await open(filePath, 'r');
|
|
558
|
+
try {
|
|
559
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
560
|
+
const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
|
|
561
|
+
const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
|
|
562
|
+
return {
|
|
563
|
+
content: buffer.subarray(0, bytesRead).toString('utf8'),
|
|
564
|
+
truncated: totalBytes > bytesRead,
|
|
565
|
+
bytesRead,
|
|
566
|
+
totalBytes,
|
|
567
|
+
};
|
|
568
|
+
} finally {
|
|
569
|
+
await file.close();
|
|
570
|
+
}
|
|
472
571
|
}
|
|
473
572
|
|
|
474
573
|
export function escapeXml(value: string): string {
|
|
475
|
-
|
|
574
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
476
575
|
}
|
|
477
576
|
|
|
478
|
-
export const UPDATE_COMMAND =
|
|
577
|
+
export const UPDATE_COMMAND = '/bg-update';
|
|
479
578
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
579
|
+
interface ParsedSemver {
|
|
580
|
+
major: number;
|
|
581
|
+
minor: number;
|
|
582
|
+
patch: number;
|
|
583
|
+
prerelease: string[];
|
|
584
|
+
}
|
|
486
585
|
|
|
487
586
|
const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
488
587
|
|
|
489
588
|
export function parseSemver(value: string): ParsedSemver | undefined {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
589
|
+
if (typeof value !== 'string') return undefined;
|
|
590
|
+
const match = SEMVER_PATTERN.exec(value.trim());
|
|
591
|
+
if (!match) return undefined;
|
|
592
|
+
const majorRaw = match[1];
|
|
593
|
+
const minorRaw = match[2];
|
|
594
|
+
const patchRaw = match[3];
|
|
595
|
+
if (majorRaw === undefined || minorRaw === undefined || patchRaw === undefined) return undefined;
|
|
596
|
+
const major = Number(majorRaw);
|
|
597
|
+
const minor = Number(minorRaw);
|
|
598
|
+
const patch = Number(patchRaw);
|
|
599
|
+
if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch))
|
|
600
|
+
return undefined;
|
|
601
|
+
const prerelease = match[4] !== undefined ? match[4].split('.') : [];
|
|
602
|
+
return { major, minor, patch, prerelease };
|
|
499
603
|
}
|
|
500
604
|
|
|
501
605
|
function comparePrerelease(a: string[], b: string[]): number {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
606
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
607
|
+
// A version without prerelease identifiers outranks the same core with prerelease identifiers.
|
|
608
|
+
if (a.length === 0) return 1;
|
|
609
|
+
if (b.length === 0) return -1;
|
|
610
|
+
const shared = Math.min(a.length, b.length);
|
|
611
|
+
for (let i = 0; i < shared; i++) {
|
|
612
|
+
const idA = a[i];
|
|
613
|
+
const idB = b[i];
|
|
614
|
+
if (idA === undefined || idB === undefined) break;
|
|
615
|
+
if (idA === idB) continue;
|
|
616
|
+
const numericA = /^\d+$/.test(idA);
|
|
617
|
+
const numericB = /^\d+$/.test(idB);
|
|
618
|
+
if (numericA && numericB) {
|
|
619
|
+
const diff = Number(idA) - Number(idB);
|
|
620
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// Numeric identifiers always have lower precedence than non-numeric identifiers.
|
|
624
|
+
if (numericA) return -1;
|
|
625
|
+
if (numericB) return 1;
|
|
626
|
+
return idA < idB ? -1 : 1;
|
|
627
|
+
}
|
|
628
|
+
if (a.length === b.length) return 0;
|
|
629
|
+
return a.length < b.length ? -1 : 1;
|
|
526
630
|
}
|
|
527
631
|
|
|
528
632
|
/** Compare two semver strings. Returns -1/0/1, or undefined when either side is not valid semver. */
|
|
529
633
|
export function compareSemver(a: string, b: string): number | undefined {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
634
|
+
const left = parseSemver(a);
|
|
635
|
+
const right = parseSemver(b);
|
|
636
|
+
if (!left || !right) return undefined;
|
|
637
|
+
if (left.major !== right.major) return left.major < right.major ? -1 : 1;
|
|
638
|
+
if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
|
|
639
|
+
if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
|
|
640
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
537
641
|
}
|
|
538
642
|
|
|
539
643
|
export function isNewerVersion(latest: string, current: string): boolean {
|
|
540
|
-
|
|
644
|
+
return compareSemver(latest, current) === 1;
|
|
541
645
|
}
|
|
542
646
|
|
|
543
647
|
/** Footer segment shown only when a newer published version exists; undefined otherwise. */
|
|
544
|
-
export function formatUpdateSegment(
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
648
|
+
export function formatUpdateSegment(
|
|
649
|
+
latest: string | undefined,
|
|
650
|
+
current: string,
|
|
651
|
+
): string | undefined {
|
|
652
|
+
if (!latest) return undefined;
|
|
653
|
+
if (!isNewerVersion(latest, current)) return undefined;
|
|
654
|
+
return `\u2b06 v${latest} ${UPDATE_COMMAND}`;
|
|
548
655
|
}
|