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/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 process group. Nonblocking.",
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
- return `Started background ${meta.kind} ${label}. Status: ${meta.status}. Log: ${meta.logPath}`;
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
- return `${meta.id} ${label}${meta.kind} ${meta.status} ${age}`;
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
- return `Background task ${id} is ${meta.status}.`;
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 {