pi-simple-background-tasks 0.0.0-stage → 0.1.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/FORK-CHANGES.md +66 -0
- package/LICENSE +15 -0
- package/README.md +100 -2
- package/package.json +59 -4
- package/src/core/common.ts +914 -0
- package/src/core/config.ts +83 -0
- package/src/core/durable-fs.ts +545 -0
- package/src/core/extension-api.ts +70 -0
- package/src/core/lazy-module.ts +55 -0
- package/src/core/registry.ts +2426 -0
- package/src/core/reload-shell-owner.ts +1512 -0
- package/src/core/shell-policy.ts +180 -0
- package/src/core/task-cleanup.ts +146 -0
- package/src/core/task-durable.ts +65 -0
- package/src/core/windows-taskkill.ts +263 -0
- package/src/extension.ts +928 -0
- package/src/ui/background-tasks-manager.ts +731 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
import { accessSync, constants, statSync, type WriteStream } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { basename, delimiter, extname, isAbsolute, join, resolve, win32 } from 'node:path';
|
|
4
|
+
import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import type { BackgroundTaskChildProcess } from './registry.js';
|
|
6
|
+
|
|
7
|
+
export const TASK_STATUS_VALUES = ['running', 'completed', 'failed', 'killed'] as const;
|
|
8
|
+
export const TERMINAL_TASK_STATUS_VALUES = ['completed', 'failed', 'killed'] as const;
|
|
9
|
+
|
|
10
|
+
export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
|
|
11
|
+
export type TerminalTaskStatus = (typeof TERMINAL_TASK_STATUS_VALUES)[number];
|
|
12
|
+
export type KillKind = 'user' | 'timeout' | 'output_cap' | 'shutdown';
|
|
13
|
+
export type ReloadShellStopKind = KillKind | 'handoff_expired';
|
|
14
|
+
|
|
15
|
+
export type TerminalPublicationState = 'pending' | 'delivered' | 'abandoned';
|
|
16
|
+
export type TerminalPublicationAbandonReason =
|
|
17
|
+
| 'registry_shutdown'
|
|
18
|
+
| 'publisher_closed'
|
|
19
|
+
| 'gate_rejected'
|
|
20
|
+
| 'retry_exhausted'
|
|
21
|
+
| 'retention_limit'
|
|
22
|
+
| 'reload_handoff_expired';
|
|
23
|
+
|
|
24
|
+
export type ReloadSurvivalErrorCode =
|
|
25
|
+
| 'pi_bg_survive_reload_invalid'
|
|
26
|
+
| 'pi_bg_survive_reload_requires_non_agent'
|
|
27
|
+
| 'pi_bg_survive_reload_unsupported_task_kind'
|
|
28
|
+
| 'pi_bg_reload_owner_unavailable'
|
|
29
|
+
| 'pi_bg_reload_owner_protocol_incompatible'
|
|
30
|
+
| 'pi_bg_reload_owner_activation_conflict'
|
|
31
|
+
| 'pi_bg_reload_owner_stale_claim'
|
|
32
|
+
| 'pi_bg_reload_handoff_expired';
|
|
33
|
+
|
|
34
|
+
export class ReloadSurvivalError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly code: ReloadSurvivalErrorCode,
|
|
37
|
+
message: string,
|
|
38
|
+
) {
|
|
39
|
+
super(`${code}: ${message}`);
|
|
40
|
+
this.name = 'ReloadSurvivalError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReloadSurvivalSnapshotV1 {
|
|
45
|
+
schemaVersion: 'pi-background-tasks.reload-shell.v1';
|
|
46
|
+
authority: 'same-process-live-owner';
|
|
47
|
+
hostPid: number;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
cwdRealpath: string;
|
|
50
|
+
launchNonce: string;
|
|
51
|
+
completionId: string;
|
|
52
|
+
spawnedAt: number;
|
|
53
|
+
childPid: number;
|
|
54
|
+
timeoutDeadlineAt?: number | undefined;
|
|
55
|
+
outputCapBytes: number;
|
|
56
|
+
posixProcessGroupId?: number | undefined;
|
|
57
|
+
windowsTreeRootPid?: number | undefined;
|
|
58
|
+
leaseGeneration: number;
|
|
59
|
+
handoffCount: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ReloadShellIdentityV1 {
|
|
63
|
+
readonly hostPid: number;
|
|
64
|
+
readonly sessionId: string;
|
|
65
|
+
readonly cwdRealpath: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ReloadShellActivationLeaseV1 {
|
|
69
|
+
readonly protocol: 'pi-background-tasks.reload-shell-owner.v1';
|
|
70
|
+
readonly hubNonce: string;
|
|
71
|
+
readonly identityKey: string;
|
|
72
|
+
readonly generation: number;
|
|
73
|
+
readonly activationNonce: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReloadShellActivationClaimV1 {
|
|
77
|
+
readonly protocol: 'pi-background-tasks.reload-shell-owner.v1';
|
|
78
|
+
readonly hubNonce: string;
|
|
79
|
+
readonly claimNonce: string;
|
|
80
|
+
readonly identity: ReloadShellIdentityV1;
|
|
81
|
+
readonly identityKey: string;
|
|
82
|
+
readonly generation: number;
|
|
83
|
+
readonly activationNonce: string;
|
|
84
|
+
readonly expiresAt?: number | undefined;
|
|
85
|
+
readonly executions: readonly ReloadableShellExecutionV1[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ReloadShellProcessV1 {
|
|
89
|
+
readonly pid?: number | undefined;
|
|
90
|
+
stdout?:
|
|
91
|
+
| {
|
|
92
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
93
|
+
off?(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
94
|
+
}
|
|
95
|
+
| null
|
|
96
|
+
| undefined;
|
|
97
|
+
stderr?:
|
|
98
|
+
| {
|
|
99
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
100
|
+
off?(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
101
|
+
}
|
|
102
|
+
| null
|
|
103
|
+
| undefined;
|
|
104
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
105
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
106
|
+
on(
|
|
107
|
+
event: 'close',
|
|
108
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
109
|
+
): unknown;
|
|
110
|
+
off?(event: 'error', listener: (error: Error) => void): unknown;
|
|
111
|
+
off?(
|
|
112
|
+
event: 'close',
|
|
113
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
114
|
+
): unknown;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ReloadShellOwnerEventSinkV1 {
|
|
118
|
+
readonly onChanged: (execution: ReloadableShellExecutionV1) => void;
|
|
119
|
+
readonly onTerminal: (execution: ReloadableShellExecutionV1) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ReloadShellNotificationState = 'disabled' | 'pending' | 'sending' | 'delivered';
|
|
123
|
+
|
|
124
|
+
export interface ReloadableShellExecutionV1 {
|
|
125
|
+
readonly protocol: 'pi-background-tasks.reload-shell-owner.v1';
|
|
126
|
+
readonly launchNonce: string;
|
|
127
|
+
readonly completionId: string;
|
|
128
|
+
readonly task: BgTask;
|
|
129
|
+
child: ReloadShellProcessV1 | undefined;
|
|
130
|
+
outputStream: WriteStream | undefined;
|
|
131
|
+
readonly spawnedAt: number;
|
|
132
|
+
readonly timeoutDeadlineAt?: number | undefined;
|
|
133
|
+
readonly outputCapBytes: number;
|
|
134
|
+
readonly terminal: Promise<BgTask>;
|
|
135
|
+
readonly requestStop: (kind: ReloadShellStopKind, reason?: string) => Promise<BgTask>;
|
|
136
|
+
phase: 'starting' | 'running' | 'stop_requested' | 'finalizing' | 'terminal' | 'released';
|
|
137
|
+
closeObservation?:
|
|
138
|
+
| {
|
|
139
|
+
code: number | null;
|
|
140
|
+
signal: NodeJS.Signals | null;
|
|
141
|
+
observedAt: number;
|
|
142
|
+
}
|
|
143
|
+
| undefined;
|
|
144
|
+
admissionCommitted: boolean;
|
|
145
|
+
notificationState: ReloadShellNotificationState;
|
|
146
|
+
readonly commitInitialMetadata: (signal?: AbortSignal) => Promise<void>;
|
|
147
|
+
readonly failAdmission: (error: Error) => void;
|
|
148
|
+
readonly setOwnerEventSink: (sink: ReloadShellOwnerEventSinkV1 | undefined) => void;
|
|
149
|
+
readonly markAdmissionCommitted: (generation: number, handoffCount: number) => void;
|
|
150
|
+
readonly updateLeaseAudit: (generation: number, handoffCount: number) => void;
|
|
151
|
+
readonly abandonReloadHandoff: () => void;
|
|
152
|
+
readonly beginNotification: (lease: ReloadShellActivationLeaseV1) => string | undefined;
|
|
153
|
+
readonly finishNotification: (token: string, delivered: boolean) => void;
|
|
154
|
+
readonly releaseResources: () => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ReloadShellHostAdapterV1 {
|
|
158
|
+
readonly activationNonce: string;
|
|
159
|
+
readonly onBound: (lease: ReloadShellActivationLeaseV1) => void;
|
|
160
|
+
readonly onChanged: (execution: ReloadableShellExecutionV1) => void;
|
|
161
|
+
readonly onTerminal: (execution: ReloadableShellExecutionV1) => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ReloadShellOwnerHubV1 {
|
|
165
|
+
readonly protocol: 'pi-background-tasks.reload-shell-owner.v1';
|
|
166
|
+
readonly hubNonce: string;
|
|
167
|
+
beginActivation(
|
|
168
|
+
identity: ReloadShellIdentityV1,
|
|
169
|
+
startReason: string,
|
|
170
|
+
activationNonce: string,
|
|
171
|
+
): ReloadShellActivationClaimV1;
|
|
172
|
+
commitActivation(
|
|
173
|
+
claim: ReloadShellActivationClaimV1,
|
|
174
|
+
adapter: ReloadShellHostAdapterV1,
|
|
175
|
+
): ReloadShellActivationLeaseV1;
|
|
176
|
+
abortActivation(claim: ReloadShellActivationClaimV1, error: unknown): void;
|
|
177
|
+
beginReloadHandoff(lease: ReloadShellActivationLeaseV1): readonly ReloadableShellExecutionV1[];
|
|
178
|
+
releaseActivation(lease: ReloadShellActivationLeaseV1): void;
|
|
179
|
+
registerExecution(
|
|
180
|
+
lease: ReloadShellActivationLeaseV1,
|
|
181
|
+
execution: ReloadableShellExecutionV1,
|
|
182
|
+
): void;
|
|
183
|
+
markAdmissionCommitted(
|
|
184
|
+
lease: ReloadShellActivationLeaseV1,
|
|
185
|
+
execution: ReloadableShellExecutionV1,
|
|
186
|
+
): void;
|
|
187
|
+
releaseExecution(
|
|
188
|
+
leaseOrClaim: ReloadShellActivationLeaseV1 | ReloadShellActivationClaimV1,
|
|
189
|
+
execution: ReloadableShellExecutionV1,
|
|
190
|
+
): void;
|
|
191
|
+
isCurrentLease(lease: ReloadShellActivationLeaseV1): boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface BgTaskSnapshot {
|
|
195
|
+
id: string;
|
|
196
|
+
name?: string | undefined;
|
|
197
|
+
command: string;
|
|
198
|
+
description?: string | undefined;
|
|
199
|
+
status: TaskStatus;
|
|
200
|
+
outputPath: string;
|
|
201
|
+
cwd: string;
|
|
202
|
+
startTime: number;
|
|
203
|
+
endTime?: number | undefined;
|
|
204
|
+
exitCode?: number | null | undefined;
|
|
205
|
+
signal?: string | null | undefined;
|
|
206
|
+
pid?: number | undefined;
|
|
207
|
+
bytesWritten: number;
|
|
208
|
+
surviveReload: boolean;
|
|
209
|
+
reloadSurvival?: ReloadSurvivalSnapshotV1 | undefined;
|
|
210
|
+
error?: string | undefined;
|
|
211
|
+
notified: boolean;
|
|
212
|
+
notifyOnCompletion: boolean;
|
|
213
|
+
triggerOnCompletion: boolean;
|
|
214
|
+
timeoutSeconds?: number | undefined;
|
|
215
|
+
/** Immutable non-secret shell selection for ordinary shell tasks. */
|
|
216
|
+
shellPolicy?: ShellPolicySnapshot | undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface BgTask extends Omit<BgTaskSnapshot, 'name'> {
|
|
220
|
+
name: string;
|
|
221
|
+
outputAbsPath: string;
|
|
222
|
+
metadataAbsPath: string;
|
|
223
|
+
eventsAbsPath?: string | undefined;
|
|
224
|
+
stderrAbsPath?: string | undefined;
|
|
225
|
+
child?: BackgroundTaskChildProcess | undefined;
|
|
226
|
+
/** Immutable in-memory ownership captured from a detached POSIX spawn; never restored from metadata. */
|
|
227
|
+
ownedPosixProcessGroupId?: number | undefined;
|
|
228
|
+
/** One-way latch preventing any later signal after group authority is released. */
|
|
229
|
+
posixProcessGroupSignalAuthorityReleased?: boolean | undefined;
|
|
230
|
+
stream?: WriteStream | undefined;
|
|
231
|
+
timeoutHandle?: NodeJS.Timeout | undefined;
|
|
232
|
+
killKind?: KillKind | undefined;
|
|
233
|
+
killSignalSent?: boolean | undefined;
|
|
234
|
+
killEscalationTimer?: NodeJS.Timeout | undefined;
|
|
235
|
+
capExceeded?: boolean | undefined;
|
|
236
|
+
finalized?: boolean | undefined;
|
|
237
|
+
/** True only after the terminal EventBus emitter returns successfully; abandonment is never delivery. */
|
|
238
|
+
terminalPublished: boolean;
|
|
239
|
+
terminalPublicationState: TerminalPublicationState;
|
|
240
|
+
terminalPublicationAbandonReason?: TerminalPublicationAbandonReason | undefined;
|
|
241
|
+
terminalPublishAttempts: number;
|
|
242
|
+
terminalPublishInFlight?: boolean | undefined;
|
|
243
|
+
/** True only while the synchronous terminal emitter itself is on the stack. */
|
|
244
|
+
terminalEmitInFlight?: boolean | undefined;
|
|
245
|
+
terminalPublishRetryHandle?: NodeJS.Timeout | undefined;
|
|
246
|
+
/** Optional protocol barrier used by EventBus run requests so early child exits cannot publish before the run response is observable. */
|
|
247
|
+
terminalPublicationGate?: Promise<void> | undefined;
|
|
248
|
+
/** In-memory same-process authority for an opted ordinary shell task; never serialized. */
|
|
249
|
+
reloadExecution?: ReloadableShellExecutionV1 | undefined;
|
|
250
|
+
/** Fresh-registry terminal host delivery state for an imported owner execution. */
|
|
251
|
+
reloadHostDeliveryInFlight?: boolean | undefined;
|
|
252
|
+
reloadHostDeliverySettled?: boolean | undefined;
|
|
253
|
+
reloadHostNotificationSettled?: boolean | undefined;
|
|
254
|
+
metadataWriteChain?: Promise<void> | undefined;
|
|
255
|
+
waiters: Array<() => void>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type CompletionDeliveryMode =
|
|
259
|
+
| 'notification-and-wake'
|
|
260
|
+
| 'notification-only'
|
|
261
|
+
| 'manual-monitoring';
|
|
262
|
+
|
|
263
|
+
interface CompletionDeliveryGuidance {
|
|
264
|
+
readonly mode: CompletionDeliveryMode;
|
|
265
|
+
readonly notificationEnabled: boolean;
|
|
266
|
+
readonly automaticWakeEnabled: boolean;
|
|
267
|
+
readonly text: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Describe the actual parent-agent completion path for one bg_run launch.
|
|
272
|
+
* A wake request cannot take effect without the notification that carries it.
|
|
273
|
+
*/
|
|
274
|
+
export function deriveCompletionDeliveryGuidance(
|
|
275
|
+
notifyOnCompletion: boolean,
|
|
276
|
+
triggerOnCompletion: boolean,
|
|
277
|
+
): CompletionDeliveryGuidance {
|
|
278
|
+
if (notifyOnCompletion && triggerOnCompletion) {
|
|
279
|
+
return {
|
|
280
|
+
mode: 'notification-and-wake',
|
|
281
|
+
notificationEnabled: true,
|
|
282
|
+
automaticWakeEnabled: true,
|
|
283
|
+
text: [
|
|
284
|
+
'Terminal notification: enabled.',
|
|
285
|
+
'Automatic follow-up turn: enabled.',
|
|
286
|
+
'Next action: do not poll or sleep merely to wait; continue only independent useful work, otherwise end this turn and wait for <background-task-notification>.',
|
|
287
|
+
].join('\n'),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (notifyOnCompletion) {
|
|
292
|
+
return {
|
|
293
|
+
mode: 'notification-only',
|
|
294
|
+
notificationEnabled: true,
|
|
295
|
+
automaticWakeEnabled: false,
|
|
296
|
+
text: [
|
|
297
|
+
'Terminal notification: enabled.',
|
|
298
|
+
'Automatic follow-up turn: disabled. The terminal notification will be delivered, but it will not start an agent turn.',
|
|
299
|
+
'Next action: automatic wake-up was explicitly disabled; use bg_status/bg_logs only when deliberate monitoring is required, without tight polling.',
|
|
300
|
+
].join('\n'),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
mode: 'manual-monitoring',
|
|
306
|
+
notificationEnabled: false,
|
|
307
|
+
automaticWakeEnabled: false,
|
|
308
|
+
text: [
|
|
309
|
+
'Terminal notification: disabled.',
|
|
310
|
+
triggerOnCompletion
|
|
311
|
+
? 'Automatic follow-up turn: disabled because terminal notifications are disabled. triggerOnCompletion has no effect while notifyOnCompletion is false.'
|
|
312
|
+
: 'Automatic follow-up turn: disabled.',
|
|
313
|
+
'Next action: completion delivery was explicitly disabled; use bg_status/bg_logs only for deliberate manual monitoring, without tight polling.',
|
|
314
|
+
].join('\n'),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface BgRunDetails {
|
|
319
|
+
task: BgTaskSnapshot;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface BgStatusDetails {
|
|
323
|
+
tasks: BgTaskSnapshot[];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface BgLogsDetails {
|
|
327
|
+
task: BgTaskSnapshot;
|
|
328
|
+
path: string;
|
|
329
|
+
bytesRead: number;
|
|
330
|
+
truncated: boolean;
|
|
331
|
+
tail: boolean;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface BgKillDetails {
|
|
335
|
+
task: BgTaskSnapshot;
|
|
336
|
+
message: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface StartTaskOptions {
|
|
340
|
+
name?: string | undefined;
|
|
341
|
+
description?: string | undefined;
|
|
342
|
+
timeoutSeconds?: number | undefined;
|
|
343
|
+
notifyOnCompletion?: boolean | undefined;
|
|
344
|
+
triggerOnCompletion?: boolean | undefined;
|
|
345
|
+
surviveReload?: boolean | undefined;
|
|
346
|
+
/** @internal EventBus protocol barrier; callers should not set this outside the extension service. */
|
|
347
|
+
terminalPublicationGate?: Promise<void> | undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const DEFAULT_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
351
|
+
export const MAX_LOG_BYTES = Math.min(DEFAULT_MAX_BYTES, 50 * 1024);
|
|
352
|
+
export const COMMAND_PREVIEW_CHARS = 90;
|
|
353
|
+
|
|
354
|
+
export function sanitizePathSegment(value: string): string {
|
|
355
|
+
const sanitized = value.replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
356
|
+
return sanitized || 'session';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stripMatchingQuotes(value: string): string {
|
|
360
|
+
const trimmed = value.trim();
|
|
361
|
+
if (trimmed.length >= 2) {
|
|
362
|
+
const first = trimmed[0];
|
|
363
|
+
const last = trimmed[trimmed.length - 1];
|
|
364
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
365
|
+
return trimmed.slice(1, -1);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return trimmed;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function compactWhitespace(value: string): string {
|
|
372
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function truncateChars(value: string, maxChars: number): string {
|
|
376
|
+
if (value.length <= maxChars) return value;
|
|
377
|
+
return `${value.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function normalizeTaskName(value: unknown): string | undefined {
|
|
381
|
+
if (typeof value !== 'string') return undefined;
|
|
382
|
+
const normalized = compactWhitespace(stripMatchingQuotes(value));
|
|
383
|
+
if (!normalized) return undefined;
|
|
384
|
+
return truncateChars(normalized, 80);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function deriveTaskNameFromCommand(command: string): string {
|
|
388
|
+
const normalized = compactWhitespace(stripMatchingQuotes(command));
|
|
389
|
+
if (!normalized) return 'Background task';
|
|
390
|
+
|
|
391
|
+
const packageScript = /^(npm|pnpm|yarn|bun)\s+(?:(run)\s+)?([^\s;&|]+)/.exec(normalized);
|
|
392
|
+
if (packageScript) {
|
|
393
|
+
const runner = packageScript[1] ?? 'npm';
|
|
394
|
+
const run = packageScript[2] !== undefined ? ' run' : '';
|
|
395
|
+
const script = packageScript[3] ?? '';
|
|
396
|
+
return truncateChars(`${runner}${run} ${script}`, 48);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const words = normalized.split(/\s+/).slice(0, 5).join(' ');
|
|
400
|
+
return truncateChars(words.length > 0 ? words : normalized, 48);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function taskDisplayName(task: {
|
|
404
|
+
name?: string | undefined;
|
|
405
|
+
description?: string | undefined;
|
|
406
|
+
command?: string | undefined;
|
|
407
|
+
id?: string | undefined;
|
|
408
|
+
}): string {
|
|
409
|
+
const commandName =
|
|
410
|
+
task.command && task.command.length > 0 ? deriveTaskNameFromCommand(task.command) : undefined;
|
|
411
|
+
return (
|
|
412
|
+
normalizeTaskName(task.name) ??
|
|
413
|
+
normalizeTaskName(task.description) ??
|
|
414
|
+
commandName ??
|
|
415
|
+
task.id ??
|
|
416
|
+
'Background task'
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function parseNameValueAndRest(valueAndRest: string): { value: string; rest: string } | undefined {
|
|
421
|
+
const input = valueAndRest.trimStart();
|
|
422
|
+
if (!input) return undefined;
|
|
423
|
+
const quote = input[0];
|
|
424
|
+
if (quote === '"' || quote === "'") {
|
|
425
|
+
let escaped = false;
|
|
426
|
+
let value = '';
|
|
427
|
+
for (let i = 1; i < input.length; i++) {
|
|
428
|
+
const char = input.charAt(i);
|
|
429
|
+
if (escaped) {
|
|
430
|
+
value += char;
|
|
431
|
+
escaped = false;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (char === '\\') {
|
|
435
|
+
escaped = true;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (char === quote) {
|
|
439
|
+
return { value, rest: input.slice(i + 1).trimStart() };
|
|
440
|
+
}
|
|
441
|
+
value += char;
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(input);
|
|
446
|
+
if (!match) return undefined;
|
|
447
|
+
const parsedValue = match[1];
|
|
448
|
+
if (parsedValue === undefined) return undefined;
|
|
449
|
+
return { value: parsedValue, rest: match[2]?.trimStart() ?? '' };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function parseBgCommandArgs(args: string): {
|
|
453
|
+
name?: string;
|
|
454
|
+
command: string;
|
|
455
|
+
surviveReload: boolean;
|
|
456
|
+
} {
|
|
457
|
+
let input = args.trim();
|
|
458
|
+
let name: string | undefined;
|
|
459
|
+
let surviveReload = false;
|
|
460
|
+
|
|
461
|
+
while (input) {
|
|
462
|
+
let consumed = false;
|
|
463
|
+
for (const prefix of ['--name=', '-n=']) {
|
|
464
|
+
if (input.startsWith(prefix)) {
|
|
465
|
+
const parsed = parseNameValueAndRest(input.slice(prefix.length));
|
|
466
|
+
if (!parsed) throw new Error(`${prefix.slice(0, -1)} requires a task name`);
|
|
467
|
+
name = normalizeTaskName(parsed.value);
|
|
468
|
+
input = parsed.rest;
|
|
469
|
+
consumed = true;
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (consumed) continue;
|
|
474
|
+
|
|
475
|
+
for (const prefix of ['--name', '-n']) {
|
|
476
|
+
if (input === prefix || input.startsWith(`${prefix} `) || input.startsWith(`${prefix}\t`)) {
|
|
477
|
+
const parsed = parseNameValueAndRest(input.slice(prefix.length));
|
|
478
|
+
if (!parsed) throw new Error(`${prefix} requires a task name`);
|
|
479
|
+
name = normalizeTaskName(parsed.value);
|
|
480
|
+
input = parsed.rest;
|
|
481
|
+
consumed = true;
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (consumed) continue;
|
|
486
|
+
|
|
487
|
+
if (
|
|
488
|
+
input === '--survive-reload' ||
|
|
489
|
+
input.startsWith('--survive-reload ') ||
|
|
490
|
+
input.startsWith('--survive-reload\t')
|
|
491
|
+
) {
|
|
492
|
+
if (surviveReload) {
|
|
493
|
+
throw new ReloadSurvivalError(
|
|
494
|
+
'pi_bg_survive_reload_invalid',
|
|
495
|
+
'/bg accepts --survive-reload at most once',
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
surviveReload = true;
|
|
499
|
+
input = input.slice('--survive-reload'.length).trimStart();
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (input.startsWith('--survive-reload=')) {
|
|
503
|
+
throw new ReloadSurvivalError(
|
|
504
|
+
'pi_bg_survive_reload_invalid',
|
|
505
|
+
'/bg accepts only the bare --survive-reload flag',
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (input === '--') {
|
|
510
|
+
input = '';
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
if (input.startsWith('-- ')) {
|
|
514
|
+
input = input.slice(3).trimStart();
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return name ? { name, command: input, surviveReload } : { command: input, surviveReload };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function formatDuration(ms: number): string {
|
|
524
|
+
if (ms < 1000) return `${String(ms)}ms`;
|
|
525
|
+
const seconds = Math.floor(ms / 1000);
|
|
526
|
+
if (seconds < 60) return `${String(seconds)}s`;
|
|
527
|
+
const minutes = Math.floor(seconds / 60);
|
|
528
|
+
const remSeconds = seconds % 60;
|
|
529
|
+
if (minutes < 60) return `${String(minutes)}m${remSeconds > 0 ? `${String(remSeconds)}s` : ''}`;
|
|
530
|
+
const hours = Math.floor(minutes / 60);
|
|
531
|
+
const remMinutes = minutes % 60;
|
|
532
|
+
return `${String(hours)}h${remMinutes > 0 ? `${String(remMinutes)}m` : ''}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function shellQuote(value: string): string {
|
|
536
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export type ShellDialect = 'cmd' | 'posix' | 'user-non-posix';
|
|
540
|
+
export type ShellPolicyDialect = ShellDialect | 'bash';
|
|
541
|
+
export type ShellPolicyName = 'inherit' | 'bash' | 'sh' | 'cmd';
|
|
542
|
+
|
|
543
|
+
/** Non-secret launch facts persisted for ordinary shell tasks. */
|
|
544
|
+
export interface ShellPolicySnapshot {
|
|
545
|
+
readonly policy: ShellPolicyName;
|
|
546
|
+
readonly executable: string;
|
|
547
|
+
readonly argvPrefix: readonly string[];
|
|
548
|
+
readonly dialect: ShellPolicyDialect;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** One immutable shell selection shared by guidance and every ordinary spawn in an activation. */
|
|
552
|
+
export interface ResolvedShellPolicy extends ShellPolicySnapshot {
|
|
553
|
+
readonly supportsPosixFunctionWrapper: boolean;
|
|
554
|
+
readonly windowsVerbatimArguments: boolean;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export interface ShellInvocation {
|
|
558
|
+
shell: string;
|
|
559
|
+
args: string[];
|
|
560
|
+
dialect: ShellDialect;
|
|
561
|
+
windowsVerbatimArguments: boolean;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export class ShellInvocationError extends Error {
|
|
565
|
+
readonly code = 'pi_bg_shell_invalid';
|
|
566
|
+
|
|
567
|
+
constructor(message: string) {
|
|
568
|
+
super(`pi_bg_shell_invalid: ${message}`);
|
|
569
|
+
this.name = 'ShellInvocationError';
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
type ShellCandidateResult =
|
|
574
|
+
| { readonly found: true }
|
|
575
|
+
| { readonly found: false; readonly diagnostic: string };
|
|
576
|
+
|
|
577
|
+
const POSIX_FUNCTION_SHELLS = new Set([
|
|
578
|
+
'sh',
|
|
579
|
+
'dash',
|
|
580
|
+
'ash',
|
|
581
|
+
'ksh',
|
|
582
|
+
'ksh93',
|
|
583
|
+
'mksh',
|
|
584
|
+
'pdksh',
|
|
585
|
+
'zsh',
|
|
586
|
+
'yash',
|
|
587
|
+
'posh',
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
function failShellInvocation(message: string): never {
|
|
591
|
+
throw new ShellInvocationError(message);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function shellErrorMessage(error: unknown): string {
|
|
595
|
+
return error instanceof Error ? error.message : String(error);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function freezeShellPolicy(
|
|
599
|
+
policy: Omit<ResolvedShellPolicy, 'argvPrefix'> & { argvPrefix: readonly string[] },
|
|
600
|
+
): ResolvedShellPolicy {
|
|
601
|
+
return Object.freeze({
|
|
602
|
+
...policy,
|
|
603
|
+
argvPrefix: Object.freeze([...policy.argvPrefix]),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function shellPolicySnapshot(policy: ResolvedShellPolicy): ShellPolicySnapshot {
|
|
608
|
+
return Object.freeze({
|
|
609
|
+
policy: policy.policy,
|
|
610
|
+
executable: policy.executable,
|
|
611
|
+
argvPrefix: Object.freeze([...policy.argvPrefix]),
|
|
612
|
+
dialect: policy.dialect,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function isWindowsExecutablePath(path: string): boolean {
|
|
617
|
+
const extension = extname(path).toLowerCase();
|
|
618
|
+
return extension === '.exe' || extension === '.com';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function validateWindowsShellPath(path: string, label: string): string {
|
|
622
|
+
if (path.length === 0) failShellInvocation(`${label} is empty`);
|
|
623
|
+
if (!isAbsolute(path) && !win32.isAbsolute(path)) {
|
|
624
|
+
failShellInvocation(`${label} must be an absolute path`);
|
|
625
|
+
}
|
|
626
|
+
if (!isWindowsExecutablePath(path)) {
|
|
627
|
+
failShellInvocation(`${label} must point to a .exe or .com file`);
|
|
628
|
+
}
|
|
629
|
+
let stats: ReturnType<typeof statSync>;
|
|
630
|
+
try {
|
|
631
|
+
stats = statSync(path);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
failShellInvocation(`${label} stat failed: ${shellErrorMessage(error)}`);
|
|
634
|
+
}
|
|
635
|
+
if (!stats.isFile()) failShellInvocation(`${label} must point to a regular file`);
|
|
636
|
+
return path;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function inspectWindowsShellCandidate(path: string): ShellCandidateResult {
|
|
640
|
+
if (!isWindowsExecutablePath(path)) {
|
|
641
|
+
return { found: false, diagnostic: `${path} is not a .exe or .com path` };
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const stats = statSync(path);
|
|
645
|
+
if (stats.isFile()) return { found: true };
|
|
646
|
+
return { found: false, diagnostic: `${path} is not a regular file` };
|
|
647
|
+
} catch (error) {
|
|
648
|
+
return { found: false, diagnostic: `${path}: ${shellErrorMessage(error)}` };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function windowsPathValue(env: NodeJS.ProcessEnv): string {
|
|
653
|
+
return env.PATH ?? env.Path ?? env.path ?? '';
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function resolveWindowsBash(env: NodeJS.ProcessEnv): string {
|
|
657
|
+
const pathValue = windowsPathValue(env);
|
|
658
|
+
const diagnostics: string[] = [];
|
|
659
|
+
for (const dir of pathValue.split(';').filter((entry) => entry.length > 0)) {
|
|
660
|
+
for (const name of ['bash.exe', 'bash.com']) {
|
|
661
|
+
const candidate = join(dir, name);
|
|
662
|
+
const result = inspectWindowsShellCandidate(candidate);
|
|
663
|
+
if (result.found) return candidate;
|
|
664
|
+
diagnostics.push(result.diagnostic);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const suffix = diagnostics.length > 0 ? `: ${diagnostics.join('; ')}` : '';
|
|
668
|
+
failShellInvocation(`PI_BG_SHELL=bash could not resolve bash.exe or bash.com on PATH${suffix}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function validatePosixShellPath(path: string, label: string): string {
|
|
672
|
+
if (path.length === 0) failShellInvocation(`${label} is empty`);
|
|
673
|
+
if (!isAbsolute(path)) failShellInvocation(`${label} must be an absolute path`);
|
|
674
|
+
let stats: ReturnType<typeof statSync>;
|
|
675
|
+
try {
|
|
676
|
+
stats = statSync(path);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
failShellInvocation(`${label} stat failed: ${shellErrorMessage(error)}`);
|
|
679
|
+
}
|
|
680
|
+
if (!stats.isFile()) failShellInvocation(`${label} must point to a regular file`);
|
|
681
|
+
try {
|
|
682
|
+
accessSync(path, constants.X_OK);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
failShellInvocation(`${label} must be executable: ${shellErrorMessage(error)}`);
|
|
685
|
+
}
|
|
686
|
+
return path;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function inspectPosixShellCandidate(path: string): boolean {
|
|
690
|
+
try {
|
|
691
|
+
const stats = statSync(path);
|
|
692
|
+
if (!stats.isFile()) return false;
|
|
693
|
+
accessSync(path, constants.X_OK);
|
|
694
|
+
return true;
|
|
695
|
+
} catch {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function resolvePosixExecutable(
|
|
701
|
+
name: 'bash' | 'sh',
|
|
702
|
+
env: NodeJS.ProcessEnv,
|
|
703
|
+
activationCwd: string,
|
|
704
|
+
): string {
|
|
705
|
+
const binCandidate = `/bin/${name}`;
|
|
706
|
+
if (inspectPosixShellCandidate(binCandidate)) return binCandidate;
|
|
707
|
+
const pathValue = env.PATH ?? '';
|
|
708
|
+
for (const entry of pathValue.split(delimiter)) {
|
|
709
|
+
if (entry.length === 0) continue;
|
|
710
|
+
const directory = isAbsolute(entry) ? entry : resolve(activationCwd, entry);
|
|
711
|
+
const candidate = join(directory, name);
|
|
712
|
+
if (inspectPosixShellCandidate(candidate)) return candidate;
|
|
713
|
+
}
|
|
714
|
+
failShellInvocation(
|
|
715
|
+
`PI_BG_POSIX_SHELL=${name} could not resolve executable ${binCandidate} or ${name} on PATH`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function inheritedPosixDialect(executable: string): ShellPolicyDialect {
|
|
720
|
+
const name = basename(executable).toLowerCase();
|
|
721
|
+
if (name === 'bash') return 'bash';
|
|
722
|
+
return POSIX_FUNCTION_SHELLS.has(name) ? 'posix' : 'user-non-posix';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Resolve one activation-stable policy. New POSIX variables are intentionally ignored on Windows. */
|
|
726
|
+
export function resolveShellPolicy(
|
|
727
|
+
platform: NodeJS.Platform = process.platform,
|
|
728
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
729
|
+
activationCwd: string = process.cwd(),
|
|
730
|
+
): ResolvedShellPolicy {
|
|
731
|
+
if (platform === 'win32') {
|
|
732
|
+
const requestedShell = env.PI_BG_SHELL;
|
|
733
|
+
const requestedPath = env.PI_BG_SHELL_PATH;
|
|
734
|
+
if (requestedShell === undefined) {
|
|
735
|
+
if (requestedPath !== undefined) failShellInvocation('PI_BG_SHELL_PATH requires PI_BG_SHELL');
|
|
736
|
+
const comSpec = env.ComSpec;
|
|
737
|
+
return freezeShellPolicy({
|
|
738
|
+
policy: 'cmd',
|
|
739
|
+
executable: comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe',
|
|
740
|
+
argvPrefix: ['/d', '/s', '/c'],
|
|
741
|
+
dialect: 'cmd',
|
|
742
|
+
supportsPosixFunctionWrapper: false,
|
|
743
|
+
windowsVerbatimArguments: true,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
if (requestedShell !== 'cmd' && requestedShell !== 'bash') {
|
|
747
|
+
failShellInvocation('PI_BG_SHELL must be exactly cmd or bash');
|
|
748
|
+
}
|
|
749
|
+
const explicitPath =
|
|
750
|
+
requestedPath !== undefined
|
|
751
|
+
? validateWindowsShellPath(requestedPath, 'PI_BG_SHELL_PATH')
|
|
752
|
+
: undefined;
|
|
753
|
+
if (requestedShell === 'cmd') {
|
|
754
|
+
const comSpec = env.ComSpec;
|
|
755
|
+
return freezeShellPolicy({
|
|
756
|
+
policy: 'cmd',
|
|
757
|
+
executable: explicitPath ?? (comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe'),
|
|
758
|
+
argvPrefix: ['/d', '/s', '/c'],
|
|
759
|
+
dialect: 'cmd',
|
|
760
|
+
supportsPosixFunctionWrapper: false,
|
|
761
|
+
windowsVerbatimArguments: true,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
return freezeShellPolicy({
|
|
765
|
+
policy: 'bash',
|
|
766
|
+
executable: explicitPath ?? resolveWindowsBash(env),
|
|
767
|
+
argvPrefix: ['-c'],
|
|
768
|
+
dialect: 'bash',
|
|
769
|
+
supportsPosixFunctionWrapper: true,
|
|
770
|
+
windowsVerbatimArguments: false,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const configuredPolicy = env.PI_BG_POSIX_SHELL;
|
|
775
|
+
if (
|
|
776
|
+
configuredPolicy !== undefined &&
|
|
777
|
+
configuredPolicy !== 'inherit' &&
|
|
778
|
+
configuredPolicy !== 'bash' &&
|
|
779
|
+
configuredPolicy !== 'sh'
|
|
780
|
+
) {
|
|
781
|
+
failShellInvocation('PI_BG_POSIX_SHELL must be exactly inherit, bash, or sh');
|
|
782
|
+
}
|
|
783
|
+
const policy = configuredPolicy ?? 'inherit';
|
|
784
|
+
const configuredPath = env.PI_BG_POSIX_SHELL_PATH;
|
|
785
|
+
if (policy === 'inherit') {
|
|
786
|
+
if (configuredPath !== undefined) {
|
|
787
|
+
failShellInvocation(
|
|
788
|
+
'PI_BG_POSIX_SHELL_PATH requires PI_BG_POSIX_SHELL=bash or PI_BG_POSIX_SHELL=sh',
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const inherited = env.SHELL;
|
|
792
|
+
const executable = inherited && inherited.length > 0 ? inherited : '/bin/sh';
|
|
793
|
+
const dialect = inheritedPosixDialect(executable);
|
|
794
|
+
return freezeShellPolicy({
|
|
795
|
+
policy,
|
|
796
|
+
executable,
|
|
797
|
+
argvPrefix: ['-c'],
|
|
798
|
+
dialect,
|
|
799
|
+
supportsPosixFunctionWrapper: dialect === 'bash' || dialect === 'posix',
|
|
800
|
+
windowsVerbatimArguments: false,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const executable =
|
|
805
|
+
configuredPath !== undefined
|
|
806
|
+
? validatePosixShellPath(configuredPath, 'PI_BG_POSIX_SHELL_PATH')
|
|
807
|
+
: resolvePosixExecutable(policy, env, activationCwd);
|
|
808
|
+
return freezeShellPolicy({
|
|
809
|
+
policy,
|
|
810
|
+
executable,
|
|
811
|
+
argvPrefix: ['-c'],
|
|
812
|
+
dialect: policy === 'bash' ? 'bash' : 'posix',
|
|
813
|
+
supportsPosixFunctionWrapper: true,
|
|
814
|
+
windowsVerbatimArguments: false,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function shellInvocationForPolicy(
|
|
819
|
+
command: string,
|
|
820
|
+
policy: ResolvedShellPolicy,
|
|
821
|
+
): ShellInvocation {
|
|
822
|
+
const dialect: ShellDialect = policy.dialect === 'bash' ? 'posix' : policy.dialect;
|
|
823
|
+
return {
|
|
824
|
+
shell: policy.executable,
|
|
825
|
+
args:
|
|
826
|
+
policy.dialect === 'cmd'
|
|
827
|
+
? [...policy.argvPrefix, `"${command}"`]
|
|
828
|
+
: [...policy.argvPrefix, command],
|
|
829
|
+
dialect,
|
|
830
|
+
windowsVerbatimArguments: policy.windowsVerbatimArguments,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
|
|
835
|
+
const raw = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
836
|
+
return Math.max(1, Math.min(MAX_LOG_BYTES, raw));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export function snapshot(task: BgTask): BgTaskSnapshot {
|
|
840
|
+
return {
|
|
841
|
+
id: task.id,
|
|
842
|
+
name: taskDisplayName(task),
|
|
843
|
+
command: task.command,
|
|
844
|
+
description: task.description,
|
|
845
|
+
status: task.status,
|
|
846
|
+
outputPath: task.outputPath,
|
|
847
|
+
cwd: task.cwd,
|
|
848
|
+
startTime: task.startTime,
|
|
849
|
+
endTime: task.endTime,
|
|
850
|
+
exitCode: task.exitCode,
|
|
851
|
+
signal: task.signal,
|
|
852
|
+
pid: task.pid,
|
|
853
|
+
bytesWritten: task.bytesWritten,
|
|
854
|
+
surviveReload: task.surviveReload === true,
|
|
855
|
+
reloadSurvival: task.reloadSurvival,
|
|
856
|
+
error: task.error,
|
|
857
|
+
notified: task.notified,
|
|
858
|
+
notifyOnCompletion: task.notifyOnCompletion,
|
|
859
|
+
triggerOnCompletion: task.triggerOnCompletion,
|
|
860
|
+
timeoutSeconds: task.timeoutSeconds,
|
|
861
|
+
shellPolicy: task.shellPolicy,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function formatSnapshotList(tasks: BgTaskSnapshot[], now = Date.now()): string {
|
|
866
|
+
if (tasks.length === 0) return 'No background tasks in this Pi extension runtime.';
|
|
867
|
+
return tasks
|
|
868
|
+
.map((task) => {
|
|
869
|
+
const statusIcon =
|
|
870
|
+
task.status === 'running'
|
|
871
|
+
? '▶'
|
|
872
|
+
: task.status === 'completed'
|
|
873
|
+
? '✓'
|
|
874
|
+
: task.status === 'killed'
|
|
875
|
+
? '■'
|
|
876
|
+
: '✗';
|
|
877
|
+
const age = formatDuration((task.endTime ?? now) - task.startTime);
|
|
878
|
+
const code = task.exitCode !== undefined ? ` exit=${String(task.exitCode)}` : '';
|
|
879
|
+
const pid = task.pid !== undefined ? ` pid=${String(task.pid)}` : '';
|
|
880
|
+
const error = task.error ? ` error=${truncateChars(task.error, 80)}` : '';
|
|
881
|
+
return `${statusIcon} ${task.id} ${task.status} ${age}${code}${pid} — ${truncateChars(taskDisplayName(task), COMMAND_PREVIEW_CHARS)}${error}\n output: ${task.outputPath}`;
|
|
882
|
+
})
|
|
883
|
+
.join('\n');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export async function boundedRead(
|
|
887
|
+
filePath: string,
|
|
888
|
+
maxBytes: number,
|
|
889
|
+
tail: boolean,
|
|
890
|
+
): Promise<{ content: string; truncated: boolean; bytesRead: number; totalBytes: number }> {
|
|
891
|
+
const stats = statSync(filePath);
|
|
892
|
+
const totalBytes = stats.size;
|
|
893
|
+
const bytesToRead = Math.min(totalBytes, maxBytes);
|
|
894
|
+
if (bytesToRead === 0) return { content: '', truncated: false, bytesRead: 0, totalBytes };
|
|
895
|
+
|
|
896
|
+
const file = await open(filePath, 'r');
|
|
897
|
+
try {
|
|
898
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
899
|
+
const position = tail ? Math.max(0, totalBytes - bytesToRead) : 0;
|
|
900
|
+
const { bytesRead } = await file.read(buffer, 0, bytesToRead, position);
|
|
901
|
+
return {
|
|
902
|
+
content: buffer.subarray(0, bytesRead).toString('utf8'),
|
|
903
|
+
truncated: totalBytes > bytesRead,
|
|
904
|
+
bytesRead,
|
|
905
|
+
totalBytes,
|
|
906
|
+
};
|
|
907
|
+
} finally {
|
|
908
|
+
await file.close();
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export function escapeXml(value: string): string {
|
|
913
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
914
|
+
}
|