pi-better-background-tasks 0.1.18 → 0.2.1

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,28 @@ 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
+ description: "Structured SSH connection for remote background tasks. Set ssh instead of wrapping command in a hand-written ssh command; command is the remote command, and Pi keeps durable local logs, status, callbacks, and remote stop semantics.",
28
+ });
29
+
30
+ const RemoteSchema = Type.Object({
31
+ 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." })),
32
+ 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." })),
33
+ workdir: Type.Optional(Type.String({ description: "Remote working directory for the spawned command." })),
34
+ }, {
35
+ description: "Remote execution controls used with ssh. Omit session for SSH spawn to get the durable tmux default; SSH watch runs direct one-shot polls regardless of session and does not install tmux.",
36
+ });
37
+
19
38
  const CommandFields = {
20
39
  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." })),
40
+ 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
41
  argv: Type.Optional(Type.Array(Type.String(), { description: "Argument vector. Use with shell:false to avoid shell parsing." })),
23
42
  shell: Type.Optional(Type.Boolean({ description: "Run command through the package's bash-compatible shell. Default true." })),
24
43
  cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the current pi cwd." })),
@@ -26,6 +45,8 @@ const CommandFields = {
26
45
  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
46
  callback: Type.Optional(Type.Boolean({ description: "Queue a follow-up when the task reaches a terminal state. Default true." })),
28
47
  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." })),
48
+ ssh: Type.Optional(SshSchema),
49
+ remote: Type.Optional(RemoteSchema),
29
50
  };
30
51
 
31
52
  const SpawnParams = Type.Object(CommandFields);
@@ -100,7 +121,7 @@ export function registerTools(pi: ExtensionAPI): void {
100
121
  pi.registerTool({
101
122
  name: "bg_task_spawn",
102
123
  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.",
124
+ description: "Start a long-running background process and return immediately with its task id. For remote work, prefer structured ssh: pass ssh:{host,user} and put the remote command in command; 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
125
  parameters: SpawnParams,
105
126
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
106
127
  activeSession = getCallbackOrigin(ctx);
@@ -113,7 +134,7 @@ export function registerTools(pi: ExtensionAPI): void {
113
134
  pi.registerTool({
114
135
  name: "bg_task_watch",
115
136
  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.",
137
+ description: "Poll a command in the background until success_when, failure_when, or timeout matches. For remote work, prefer structured ssh: pass ssh:{host,user} 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
138
  parameters: WatchParams,
118
139
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
119
140
  activeSession = getCallbackOrigin(ctx);
@@ -160,11 +181,11 @@ export function registerTools(pi: ExtensionAPI): void {
160
181
  pi.registerTool({
161
182
  name: "bg_task_stop",
162
183
  label: "BG Stop",
163
- description: "Cancel a watcher or terminate a background process group. Nonblocking.",
184
+ 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
185
  parameters: IdParams,
165
186
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
166
187
  activeSession = getCallbackOrigin(ctx);
167
- const result = formatStop(pi, params.id, ctx, getActiveSession);
188
+ const result = await formatStop(pi, params.id, ctx, getActiveSession);
168
189
  refreshBackgroundTasksNavigator(ctx);
169
190
  return text(result);
170
191
  },
@@ -173,7 +194,7 @@ export function registerTools(pi: ExtensionAPI): void {
173
194
  pi.registerTool({
174
195
  name: "bg_task",
175
196
  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.",
197
+ description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. For remote work, prefer structured ssh: pass ssh:{host,user} 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
198
  parameters: ActionParams,
178
199
  renderResult(result: unknown, options: unknown, theme: unknown) {
179
200
  return renderBackgroundTaskLogDisplay(result, options, theme);
@@ -203,7 +224,7 @@ export function text(textValue: string, details?: unknown) {
203
224
  return { content: [{ type: "text" as const, text: textValue }], details };
204
225
  }
205
226
 
206
- function actionText(
227
+ async function actionText(
207
228
  pi: ExtensionAPI,
208
229
  params: Record<string, unknown>,
209
230
  ctx: ExtensionContext,
@@ -213,16 +234,16 @@ function actionText(
213
234
  if (params.action === "log" && params.id) {
214
235
  return logText(String(params.id), params.tail_lines as number | undefined);
215
236
  }
216
- return text(runAction(pi, params, ctx, callbackOrigin, getActiveSession));
237
+ return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession));
217
238
  }
218
239
 
219
- function runAction(
240
+ async function runAction(
220
241
  pi: ExtensionAPI,
221
242
  params: Record<string, unknown>,
222
243
  ctx: ExtensionContext,
223
244
  callbackOrigin: BackgroundTaskCallbackOrigin,
224
245
  getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
225
- ): string {
246
+ ): Promise<string> {
226
247
  switch (params.action) {
227
248
  case "spawn":
228
249
  return withNavigatorRefresh(ctx, formatLaunch(spawnTask(pi, params, ctx.cwd, callbackOrigin, getActiveSession)));
@@ -239,7 +260,7 @@ function runAction(
239
260
  return formatLog(String(params.id), params.tail_lines as number | undefined);
240
261
  case "stop":
241
262
  if (!params.id) return "Invalid parameters: stop requires id.";
242
- return withNavigatorRefresh(ctx, formatStop(pi, String(params.id), ctx, getActiveSession));
263
+ return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession));
243
264
  case "clear":
244
265
  return withNavigatorRefresh(ctx, formatClear(params.status as string[] | undefined, callbackOrigin));
245
266
  default:
@@ -271,9 +292,14 @@ function resolveList(statuses?: string[], limit?: number): BackgroundTaskMeta[]
271
292
  return metas.slice(0, Math.max(1, Math.min(limit ?? 20, 100)));
272
293
  }
273
294
 
274
- function formatLaunch(meta: BackgroundTaskMeta): string {
295
+ export function formatLaunch(meta: BackgroundTaskMeta): string {
275
296
  const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
276
- return `Started background ${meta.kind} ${label}. Status: ${meta.status}. Log: ${meta.logPath}`;
297
+ const remote = meta.ssh
298
+ ? ` Remote: ${meta.ssh.target}${meta.remote?.session ? ` mode=${meta.remote.session}` : ""}${meta.remote?.sessionName ? ` session=${meta.remote.sessionName}` : ""}.`
299
+ : "";
300
+ const setup = meta.remote?.bootstrapMessage ? ` Remote setup: ${meta.remote.bootstrapMessage}` : "";
301
+ const warning = meta.remote?.warning ? ` Warning: ${meta.remote.warning}` : "";
302
+ return `Started background ${meta.kind} ${label}. Status: ${meta.status}.${remote}${setup}${warning} Log: ${meta.logPath}`;
277
303
  }
278
304
 
279
305
  function formatList(metas: BackgroundTaskMeta[]): string {
@@ -281,7 +307,8 @@ function formatList(metas: BackgroundTaskMeta[]): string {
281
307
  return metas.map((meta) => {
282
308
  const age = formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt);
283
309
  const label = meta.name ? `${meta.name} ` : "";
284
- return `${meta.id} ${label}${meta.kind} ${meta.status} ${age}`;
310
+ const remote = meta.ssh ? ` ${meta.ssh.target}${meta.remote?.session ? ` ${meta.remote.session}` : ""}` : "";
311
+ return `${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
285
312
  }).join("\n");
286
313
  }
287
314
 
@@ -297,6 +324,12 @@ function formatCompactStatus(meta: BackgroundTaskMeta): string {
297
324
  `kind: ${meta.kind}`,
298
325
  `elapsed: ${formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt)}`,
299
326
  ];
327
+ if (meta.ssh) lines.push(`remote: ${meta.ssh.target}`);
328
+ if (meta.remote?.session) lines.push(`remote mode: ${meta.remote.session}`);
329
+ if (meta.remote?.sessionName) lines.push(`remote session: ${meta.remote.sessionName}`);
330
+ if (meta.remote?.bootstrapMessage) lines.push(`remote setup: ${oneLine(meta.remote.bootstrapMessage, 500)}`);
331
+ if (meta.remote?.warning) lines.push(`warning: ${oneLine(meta.remote.warning, 500)}`);
332
+ if (meta.remote?.stopMessage) lines.push(`remote stop: ${oneLine(meta.remote.stopMessage, 500)}`);
300
333
  if (meta.deadlineAt && meta.status === "running") lines.push(`deadline: ${formatDuration(meta.deadlineAt - Date.now())} left`);
301
334
  if (meta.lastExitCode !== undefined || meta.lastSignal !== undefined) lines.push(`last exit: ${meta.lastExitCode ?? "null"}${meta.lastSignal ? ` signal=${meta.lastSignal}` : ""}`);
302
335
  const reason = resultReason(meta.result);
@@ -433,15 +466,17 @@ function truncateToVisibleWidth(value: string, width: number): string {
433
466
  return String(value ?? "").slice(0, max);
434
467
  }
435
468
 
436
- function formatStop(
469
+ async function formatStop(
437
470
  pi: ExtensionAPI,
438
471
  id: string,
439
472
  _ctx: ExtensionContext,
440
473
  getActiveSession?: () => BackgroundTaskCallbackOrigin | undefined,
441
- ): string {
442
- const meta = stopTask(pi, id, getActiveSession);
474
+ ): Promise<string> {
475
+ const meta = await stopTask(pi, id, getActiveSession);
443
476
  if (!meta) return `No background task found for id ${id}.`;
444
- return `Background task ${id} is ${meta.status}.`;
477
+ const remoteStop = meta.remote?.stopMessage ? ` ${meta.remote.stopMessage}` : "";
478
+ const weakStop = meta.remote?.session === "direct" ? ` Warning: ${meta.remote.warning}` : "";
479
+ return `Background task ${id} is ${meta.status}.${remoteStop}${weakStop}`;
445
480
  }
446
481
 
447
482
  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 {