pi-better-background-tasks 0.1.18 → 0.2.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/README.md +25 -0
- package/package.json +2 -1
- package/src/logs.ts +6 -0
- package/src/navigator-provider.ts +10 -3
- package/src/process.ts +25 -3
- package/src/registry.ts +5 -1
- package/src/remote-task-preset.ts +631 -0
- package/src/runtime.ts +483 -55
- package/src/tools.ts +49 -18
- package/src/types.ts +43 -0
package/src/tools.ts
CHANGED
|
@@ -16,9 +16,24 @@ const ConditionSchema = Type.Union([
|
|
|
16
16
|
Type.Object({ type: Type.Literal("json_path_exists"), path: Type.String() }),
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
+
const SshSchema = Type.Object({
|
|
20
|
+
host: Type.String({ description: "SSH host. Required when ssh is set." }),
|
|
21
|
+
user: Type.Optional(Type.String({ description: "SSH user." })),
|
|
22
|
+
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535, description: "SSH port." })),
|
|
23
|
+
identity_file: Type.Optional(Type.String({ description: "SSH identity file path." })),
|
|
24
|
+
jump: Type.Optional(Type.String({ description: "SSH jump host passed with -J." })),
|
|
25
|
+
options: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Additional SSH -o key/value options. Agent-safe defaults remain enforced." })),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const RemoteSchema = Type.Object({
|
|
29
|
+
session: Type.Optional(Type.Union([Type.Literal("tmux"), Type.Literal("direct")], { description: "Remote session mode. SSH spawn defaults to durable tmux. Watch always uses direct one-shot polls. Explicit direct spawn has weaker stop semantics." })),
|
|
30
|
+
install_tmux: Type.Optional(Type.Boolean({ description: "Allow SSH spawn to install tmux non-interactively when missing. Defaults true in tmux mode and is ignored for watch and direct spawn." })),
|
|
31
|
+
workdir: Type.Optional(Type.String({ description: "Remote working directory for the spawned command." })),
|
|
32
|
+
});
|
|
33
|
+
|
|
19
34
|
const CommandFields = {
|
|
20
35
|
name: Type.Optional(Type.String({ description: "Human-readable task label." })),
|
|
21
|
-
command: Type.Optional(Type.String({ description: "Shell command to run. Required unless shell:false with argv is used." })),
|
|
36
|
+
command: Type.Optional(Type.String({ description: "Shell command to run, or the remote command when ssh is set. Required unless shell:false with argv is used." })),
|
|
22
37
|
argv: Type.Optional(Type.Array(Type.String(), { description: "Argument vector. Use with shell:false to avoid shell parsing." })),
|
|
23
38
|
shell: Type.Optional(Type.Boolean({ description: "Run command through the package's bash-compatible shell. Default true." })),
|
|
24
39
|
cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the current pi cwd." })),
|
|
@@ -26,6 +41,8 @@ const CommandFields = {
|
|
|
26
41
|
max_log_bytes: Type.Optional(Type.Number({ description: "Maximum retained raw-log bytes. Default 4194304 (4 MiB). Older output is compacted while the task runs." })),
|
|
27
42
|
callback: Type.Optional(Type.Boolean({ description: "Queue a follow-up when the task reaches a terminal state. Default true." })),
|
|
28
43
|
timeout_seconds: Type.Optional(Type.Number({ description: "Optional timeout in seconds. Command watchers default to 900 seconds when omitted; pass 0 to disable. Spawned processes have no default timeout." })),
|
|
44
|
+
ssh: Type.Optional(SshSchema),
|
|
45
|
+
remote: Type.Optional(RemoteSchema),
|
|
29
46
|
};
|
|
30
47
|
|
|
31
48
|
const SpawnParams = Type.Object(CommandFields);
|
|
@@ -100,7 +117,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
100
117
|
pi.registerTool({
|
|
101
118
|
name: "bg_task_spawn",
|
|
102
119
|
label: "BG Spawn",
|
|
103
|
-
description: "Start a long-running background process and return immediately with its task id. Never wait or poll in the foreground.",
|
|
120
|
+
description: "Start a long-running background process and return immediately with its task id. For remote work, prefer structured ssh: spawn defaults to a remote tmux session with durable local logs and real remote stop. If tmux is missing, the preset attempts to install tmux non-interactively and fails closed with operator guidance when setup cannot proceed. Explicit remote.session=direct skips tmux, but direct mode has weaker stop semantics and may leave the remote process running. Never wait or poll in the foreground.",
|
|
104
121
|
parameters: SpawnParams,
|
|
105
122
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
106
123
|
activeSession = getCallbackOrigin(ctx);
|
|
@@ -113,7 +130,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
113
130
|
pi.registerTool({
|
|
114
131
|
name: "bg_task_watch",
|
|
115
132
|
label: "BG Watch",
|
|
116
|
-
description: "Poll a command in the background until success_when, failure_when, or timeout matches. Returns immediately with its task id. Default timeout 900 seconds; pass timeout_seconds:0 to disable.",
|
|
133
|
+
description: "Poll a command in the background until success_when, failure_when, or timeout matches. For remote work, pass structured ssh fields and provide the remote command in command; each interval opens a direct one-shot SSH poll without tmux installation. Returns immediately with its task id. Default timeout 900 seconds; pass timeout_seconds:0 to disable.",
|
|
117
134
|
parameters: WatchParams,
|
|
118
135
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
119
136
|
activeSession = getCallbackOrigin(ctx);
|
|
@@ -160,11 +177,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
160
177
|
pi.registerTool({
|
|
161
178
|
name: "bg_task_stop",
|
|
162
179
|
label: "BG Stop",
|
|
163
|
-
description: "Cancel a watcher or terminate a background
|
|
180
|
+
description: "Cancel a watcher or terminate a background task. For a tmux-backed SSH task, stop kills its remote tmux session before marking it cancelled. Direct SSH stop only tears down the local client and may leave the remote process running.",
|
|
164
181
|
parameters: IdParams,
|
|
165
182
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
166
183
|
activeSession = getCallbackOrigin(ctx);
|
|
167
|
-
const result = formatStop(pi, params.id, ctx, getActiveSession);
|
|
184
|
+
const result = await formatStop(pi, params.id, ctx, getActiveSession);
|
|
168
185
|
refreshBackgroundTasksNavigator(ctx);
|
|
169
186
|
return text(result);
|
|
170
187
|
},
|
|
@@ -173,7 +190,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
173
190
|
pi.registerTool({
|
|
174
191
|
name: "bg_task",
|
|
175
192
|
label: "BG Task",
|
|
176
|
-
description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. Spawn/watch return immediately; do not poll in foreground. For action:status, default compact output and use verbose:true only for full metadata. For action:log, default compact tail and use tail_lines:0 only for explicit full logs.",
|
|
193
|
+
description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. For remote work, pass structured ssh fields and provide the remote command in command. SSH spawn defaults to durable tmux; SSH watches use direct one-shot polls without tmux installation; remote.session=direct is a weaker-stop spawn escape hatch. Spawn/watch return immediately; do not poll in foreground. For action:status, default compact output and use verbose:true only for full metadata. For action:log, default compact tail and use tail_lines:0 only for explicit full logs.",
|
|
177
194
|
parameters: ActionParams,
|
|
178
195
|
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
179
196
|
return renderBackgroundTaskLogDisplay(result, options, theme);
|
|
@@ -203,7 +220,7 @@ export function text(textValue: string, details?: unknown) {
|
|
|
203
220
|
return { content: [{ type: "text" as const, text: textValue }], details };
|
|
204
221
|
}
|
|
205
222
|
|
|
206
|
-
function actionText(
|
|
223
|
+
async function actionText(
|
|
207
224
|
pi: ExtensionAPI,
|
|
208
225
|
params: Record<string, unknown>,
|
|
209
226
|
ctx: ExtensionContext,
|
|
@@ -213,16 +230,16 @@ function actionText(
|
|
|
213
230
|
if (params.action === "log" && params.id) {
|
|
214
231
|
return logText(String(params.id), params.tail_lines as number | undefined);
|
|
215
232
|
}
|
|
216
|
-
return text(runAction(pi, params, ctx, callbackOrigin, getActiveSession));
|
|
233
|
+
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession));
|
|
217
234
|
}
|
|
218
235
|
|
|
219
|
-
function runAction(
|
|
236
|
+
async function runAction(
|
|
220
237
|
pi: ExtensionAPI,
|
|
221
238
|
params: Record<string, unknown>,
|
|
222
239
|
ctx: ExtensionContext,
|
|
223
240
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
224
241
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
225
|
-
): string {
|
|
242
|
+
): Promise<string> {
|
|
226
243
|
switch (params.action) {
|
|
227
244
|
case "spawn":
|
|
228
245
|
return withNavigatorRefresh(ctx, formatLaunch(spawnTask(pi, params, ctx.cwd, callbackOrigin, getActiveSession)));
|
|
@@ -239,7 +256,7 @@ function runAction(
|
|
|
239
256
|
return formatLog(String(params.id), params.tail_lines as number | undefined);
|
|
240
257
|
case "stop":
|
|
241
258
|
if (!params.id) return "Invalid parameters: stop requires id.";
|
|
242
|
-
return withNavigatorRefresh(ctx, formatStop(pi, String(params.id), ctx, getActiveSession));
|
|
259
|
+
return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession));
|
|
243
260
|
case "clear":
|
|
244
261
|
return withNavigatorRefresh(ctx, formatClear(params.status as string[] | undefined, callbackOrigin));
|
|
245
262
|
default:
|
|
@@ -271,9 +288,14 @@ function resolveList(statuses?: string[], limit?: number): BackgroundTaskMeta[]
|
|
|
271
288
|
return metas.slice(0, Math.max(1, Math.min(limit ?? 20, 100)));
|
|
272
289
|
}
|
|
273
290
|
|
|
274
|
-
function formatLaunch(meta: BackgroundTaskMeta): string {
|
|
291
|
+
export function formatLaunch(meta: BackgroundTaskMeta): string {
|
|
275
292
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
276
|
-
|
|
293
|
+
const remote = meta.ssh
|
|
294
|
+
? ` Remote: ${meta.ssh.target}${meta.remote?.session ? ` mode=${meta.remote.session}` : ""}${meta.remote?.sessionName ? ` session=${meta.remote.sessionName}` : ""}.`
|
|
295
|
+
: "";
|
|
296
|
+
const setup = meta.remote?.bootstrapMessage ? ` Remote setup: ${meta.remote.bootstrapMessage}` : "";
|
|
297
|
+
const warning = meta.remote?.warning ? ` Warning: ${meta.remote.warning}` : "";
|
|
298
|
+
return `Started background ${meta.kind} ${label}. Status: ${meta.status}.${remote}${setup}${warning} Log: ${meta.logPath}`;
|
|
277
299
|
}
|
|
278
300
|
|
|
279
301
|
function formatList(metas: BackgroundTaskMeta[]): string {
|
|
@@ -281,7 +303,8 @@ function formatList(metas: BackgroundTaskMeta[]): string {
|
|
|
281
303
|
return metas.map((meta) => {
|
|
282
304
|
const age = formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
283
305
|
const label = meta.name ? `${meta.name} ` : "";
|
|
284
|
-
|
|
306
|
+
const remote = meta.ssh ? ` ${meta.ssh.target}${meta.remote?.session ? ` ${meta.remote.session}` : ""}` : "";
|
|
307
|
+
return `${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
|
|
285
308
|
}).join("\n");
|
|
286
309
|
}
|
|
287
310
|
|
|
@@ -297,6 +320,12 @@ function formatCompactStatus(meta: BackgroundTaskMeta): string {
|
|
|
297
320
|
`kind: ${meta.kind}`,
|
|
298
321
|
`elapsed: ${formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt)}`,
|
|
299
322
|
];
|
|
323
|
+
if (meta.ssh) lines.push(`remote: ${meta.ssh.target}`);
|
|
324
|
+
if (meta.remote?.session) lines.push(`remote mode: ${meta.remote.session}`);
|
|
325
|
+
if (meta.remote?.sessionName) lines.push(`remote session: ${meta.remote.sessionName}`);
|
|
326
|
+
if (meta.remote?.bootstrapMessage) lines.push(`remote setup: ${oneLine(meta.remote.bootstrapMessage, 500)}`);
|
|
327
|
+
if (meta.remote?.warning) lines.push(`warning: ${oneLine(meta.remote.warning, 500)}`);
|
|
328
|
+
if (meta.remote?.stopMessage) lines.push(`remote stop: ${oneLine(meta.remote.stopMessage, 500)}`);
|
|
300
329
|
if (meta.deadlineAt && meta.status === "running") lines.push(`deadline: ${formatDuration(meta.deadlineAt - Date.now())} left`);
|
|
301
330
|
if (meta.lastExitCode !== undefined || meta.lastSignal !== undefined) lines.push(`last exit: ${meta.lastExitCode ?? "null"}${meta.lastSignal ? ` signal=${meta.lastSignal}` : ""}`);
|
|
302
331
|
const reason = resultReason(meta.result);
|
|
@@ -433,15 +462,17 @@ function truncateToVisibleWidth(value: string, width: number): string {
|
|
|
433
462
|
return String(value ?? "").slice(0, max);
|
|
434
463
|
}
|
|
435
464
|
|
|
436
|
-
function formatStop(
|
|
465
|
+
async function formatStop(
|
|
437
466
|
pi: ExtensionAPI,
|
|
438
467
|
id: string,
|
|
439
468
|
_ctx: ExtensionContext,
|
|
440
469
|
getActiveSession?: () => BackgroundTaskCallbackOrigin | undefined,
|
|
441
|
-
): string {
|
|
442
|
-
const meta = stopTask(pi, id, getActiveSession);
|
|
470
|
+
): Promise<string> {
|
|
471
|
+
const meta = await stopTask(pi, id, getActiveSession);
|
|
443
472
|
if (!meta) return `No background task found for id ${id}.`;
|
|
444
|
-
|
|
473
|
+
const remoteStop = meta.remote?.stopMessage ? ` ${meta.remote.stopMessage}` : "";
|
|
474
|
+
const weakStop = meta.remote?.session === "direct" ? ` Warning: ${meta.remote.warning}` : "";
|
|
475
|
+
return `Background task ${id} is ${meta.status}.${remoteStop}${weakStop}`;
|
|
445
476
|
}
|
|
446
477
|
|
|
447
478
|
function formatClear(statuses: string[] | undefined, active: BackgroundTaskCallbackOrigin): string {
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,47 @@ export interface CommandResult {
|
|
|
29
29
|
stderr: string;
|
|
30
30
|
startedAt: number;
|
|
31
31
|
endedAt: number;
|
|
32
|
+
timedOut?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SshConnectionParams {
|
|
36
|
+
host: string;
|
|
37
|
+
user?: string;
|
|
38
|
+
port?: number;
|
|
39
|
+
identity_file?: string;
|
|
40
|
+
jump?: string;
|
|
41
|
+
options?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RemoteTaskParams {
|
|
45
|
+
session?: "tmux" | "direct";
|
|
46
|
+
install_tmux?: boolean;
|
|
47
|
+
workdir?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolvedSshIdentity {
|
|
51
|
+
host: string;
|
|
52
|
+
user?: string;
|
|
53
|
+
port?: number;
|
|
54
|
+
identityFile?: string;
|
|
55
|
+
jump?: string;
|
|
56
|
+
options?: Record<string, string>;
|
|
57
|
+
target: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ResolvedRemoteTaskMetadata {
|
|
61
|
+
command: string;
|
|
62
|
+
session?: "tmux" | "direct";
|
|
63
|
+
installTmux?: boolean;
|
|
64
|
+
workdir?: string;
|
|
65
|
+
sessionName?: string;
|
|
66
|
+
bootstrapStatus?: "pending" | "present" | "installed" | "needs_user" | "unknown_package_manager" | "install_failed" | "timed_out";
|
|
67
|
+
bootstrapMessage?: string;
|
|
68
|
+
tmuxInstalled?: boolean;
|
|
69
|
+
sessionStarted?: boolean;
|
|
70
|
+
logOffset?: number;
|
|
71
|
+
warning?: string;
|
|
72
|
+
stopMessage?: string;
|
|
32
73
|
}
|
|
33
74
|
|
|
34
75
|
export interface BackgroundTaskCallbackOrigin {
|
|
@@ -75,6 +116,8 @@ export interface BackgroundTaskMeta {
|
|
|
75
116
|
notifyOn?: "terminal";
|
|
76
117
|
stopRequestedAt?: number;
|
|
77
118
|
error?: string;
|
|
119
|
+
ssh?: ResolvedSshIdentity;
|
|
120
|
+
remote?: ResolvedRemoteTaskMetadata;
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
export interface TerminalResult {
|