pi-better-background-tasks 0.2.20 → 0.4.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
@@ -1,17 +1,28 @@
1
1
  import { Type } from "typebox";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { failurePath, failureSummary } from "./failures.js";
4
- import { readFailureState } from "./shared-failure-observations.js";
5
- import { readLog } from "./logs.js";
6
3
  import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
4
+ import {
5
+ classifyOwnership,
6
+ formatLaunch,
7
+ formatList,
8
+ formatLog,
9
+ formatMissingTask,
10
+ formatMutationRefusal,
11
+ formatStatus,
12
+ formatStopResult,
13
+ type OutputOptions,
14
+ } from "./output.js";
15
+ import { readOutputControls } from "./shared-log-utils.js";
7
16
  import { cancelCallbackBatch } from "./shared-callback-batcher.js";
8
- import { listMetas, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
17
+ import { inspectMeta, listMetasForOrigin, writeMeta } from "./registry.js";
9
18
  import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
10
19
  import { runTaskMaintenance } from "./maintenance.js";
11
20
  import { ForegroundSandboxBlockedError } from "./sandbox.js";
12
21
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
13
22
  import { isTerminalStatus } from "./types.js";
14
23
 
24
+ export { formatLaunch } from "./output.js";
25
+
15
26
  const JsonPathSchema = Type.String({
16
27
  pattern: "^\\$",
17
28
  description: "Root-prefixed JSON path, e.g. $.status, $.terminalFailure, or $.steps[0].status. Bare keys and wildcards are unsupported.",
@@ -25,13 +36,22 @@ const ConditionSchema = Type.Union([
25
36
  Type.Object({ type: Type.Literal("json_path_exists"), path: JsonPathSchema }),
26
37
  ]);
27
38
 
39
+ /**
40
+ * String-to-string map declared with additionalProperties. Equivalent to
41
+ * Type.Record(Type.String(), Type.String()) for accepted values; kept as the
42
+ * plainer JSON Schema form.
43
+ */
44
+ function StringMap(description: string) {
45
+ return Type.Unsafe<Record<string, string>>({ type: "object", additionalProperties: { type: "string" }, description });
46
+ }
47
+
28
48
  const SshSchema = Type.Object({
29
49
  host: Type.String({ description: "SSH host. Required when ssh is set." }),
30
50
  user: Type.Optional(Type.String({ description: "SSH user." })),
31
51
  port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535, description: "SSH port." })),
32
52
  identity_file: Type.Optional(Type.String({ description: "SSH identity file path." })),
33
53
  jump: Type.Optional(Type.String({ description: "SSH jump host passed with -J." })),
34
- options: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Additional SSH -o key/value options. Agent-safe defaults remain enforced." })),
54
+ options: Type.Optional(StringMap("Additional SSH -o key/value options. Agent-safe defaults remain enforced.")),
35
55
  }, {
36
56
  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.",
37
57
  });
@@ -44,18 +64,29 @@ const RemoteSchema = Type.Object({
44
64
  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.",
45
65
  });
46
66
 
67
+ // Structured intent (#325). Validated in code before launch (no uniqueItems/pattern: some providers reject them).
68
+ // Each field also admits null, which means "not declared": models send optional fields as explicit
69
+ // null, and readTaskIntent treats null exactly like an omitted field on every Pi version. Plain
70
+ // JSON Schema `anyOf` (Type.Unsafe), not Type.Union: Pi 0.82 converts a null to "" or [] through a
71
+ // TypeBox union, and a Null-first union reports a malformed array as "must be null".
72
+ const IntentFields = {
73
+ operation_id: Type.Optional(Type.Unsafe<string | null>({ anyOf: [{ type: "null" }, { type: "string" }], description: "Optional stable id (letters, digits, . _ : / -; up to 64) for one logical operation. Reuse it when you retry with a changed command, scope, or timeout: when a later task with the same operation_id succeeds, earlier failures of that operation in this session are recovered." })),
74
+ expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 1, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
75
+ };
76
+
47
77
  const CommandFields = {
48
78
  name: Type.Optional(Type.String({ description: "Human-readable task label." })),
49
79
  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." })),
50
80
  argv: Type.Optional(Type.Array(Type.String(), { description: "Argument vector. Use with shell:false to avoid shell parsing." })),
51
81
  shell: Type.Optional(Type.Boolean({ description: "Run command through the package's bash-compatible shell. Default true." })),
52
82
  cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the current pi cwd." })),
53
- env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Extra environment variables." })),
83
+ env: Type.Optional(StringMap("Extra environment variables.")),
54
84
  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." })),
55
85
  callback: Type.Optional(Type.Boolean({ description: "Queue a follow-up when the task reaches a terminal state. Default true." })),
56
86
  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." })),
57
87
  ssh: Type.Optional(SshSchema),
58
88
  remote: Type.Optional(RemoteSchema),
89
+ ...IntentFields,
59
90
  };
60
91
 
61
92
  const SpawnParams = Type.Object(CommandFields);
@@ -67,17 +98,38 @@ const WatchParams = Type.Object({
67
98
  failure_when: Type.Optional(ConditionSchema),
68
99
  });
69
100
 
101
+ const CursorFields = {
102
+ cursor: Type.Optional(Type.String({ description: "Caller-owned continuation cursor from a previous page. Replay returns the same page or a no-change/failure-only notice; nextCursor continues. Independent callers do not consume each other. Cursors are bound to the selected session scope." })),
103
+ max_bytes: Type.Optional(Type.Number({ description: "Optional UTF-8 byte budget. Defaults: status/log/list 1 KiB, raw evidence 16 KiB. Larger explicit pages are allowed up to the shared hard cap (status 2 KiB, log/list 4 KiB, raw 64 KiB). Hard caps are OUTPUT_BUDGET_MAX_BYTES." })),
104
+ maxBytes: Type.Optional(Type.Number({ description: "Deprecated alias for max_bytes. max_bytes wins when both are given." })),
105
+ all: Type.Optional(Type.Boolean({ description: "Inspect, list, stop, or clear-by-id tasks across every session. Default false is current-session only. Unknown ownership is reported as a gap, never as missing or healthy; stop and clear refuse tasks outside the current session." })),
106
+ };
107
+
70
108
  const IdParams = Type.Object({
71
109
  id: Type.String({ description: "Background task id." }),
72
- verbose: Type.Optional(Type.Boolean({ description: "Return full raw metadata JSON. Default false returns the compact model-facing summary. Use true only for debugging or explicit recovery." })),
110
+ verbose: Type.Optional(Type.Boolean({ description: "Return raw metadata JSON with environment values omitted, bounded like raw evidence (16 KiB default, max_bytes up to 64 KiB, paged with cursor when larger). Default false returns the compact model-facing summary. Use true only for debugging or explicit recovery." })),
111
+ ...CursorFields,
112
+ });
113
+ const HistoryField = {
114
+ history: Type.Optional(Type.Boolean({ description: "If true, return an incident page that also lists failure history: expected failures and recovered or superseded incidents. Default output lists only failures that need action and counts the rest." })),
115
+ };
116
+ const StatusParams = Type.Object({
117
+ id: Type.String({ description: "Background task id." }),
118
+ verbose: Type.Optional(Type.Boolean({ description: "Return raw metadata JSON with environment values omitted, bounded like raw evidence (16 KiB default, max_bytes up to 64 KiB, paged with cursor when larger). Default false returns the compact model-facing summary. Use true only for debugging or explicit recovery." })),
119
+ ...HistoryField,
120
+ ...CursorFields,
73
121
  });
74
122
  const ListParams = Type.Object({
75
123
  status: Type.Optional(Type.Array(Type.String({ description: "Statuses to include." }))),
76
- limit: Type.Optional(Type.Number({ description: "Maximum tasks to show. Default 20." })),
124
+ limit: Type.Optional(Type.Number({ description: "Maximum tasks to show. Default 10, max 100." })),
125
+ ...CursorFields,
77
126
  });
78
127
  const LogParams = Type.Object({
79
128
  id: Type.String({ description: "Background task id." }),
80
- tail_lines: Type.Optional(Type.Number({ description: "Number of trailing lines. Default 5 for compact model ingestion. Set <=0 only when the full log is explicitly required." })),
129
+ lines: Type.Optional(Type.Number({ description: "Number of trailing display rows. Default 10 for compact model ingestion. Set 0 to page retained raw bytes from the oldest retained offset." })),
130
+ tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines. lines wins when both are given." })),
131
+ raw: Type.Optional(Type.Boolean({ description: "Page retained raw log bytes (16 KiB default, 64 KiB hard cap) instead of the compact excerpt. Capture and retention loss are disclosed; this is not a full-history archive." })),
132
+ ...CursorFields,
81
133
  });
82
134
 
83
135
  const ActionParams = Type.Object({
@@ -93,12 +145,16 @@ const ActionParams = Type.Object({
93
145
  id: Type.Optional(Type.String()),
94
146
  status: Type.Optional(Type.Array(Type.String())),
95
147
  limit: Type.Optional(Type.Number()),
96
- tail_lines: Type.Optional(Type.Number()),
148
+ lines: Type.Optional(Type.Number({ description: "action:log trailing display rows (default 10; 0 pages raw bytes)." })),
149
+ tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines." })),
97
150
  verbose: Type.Optional(Type.Boolean()),
98
151
  ...CommandFields,
99
152
  interval_seconds: Type.Optional(Type.Number()),
100
153
  success_when: Type.Optional(ConditionSchema),
101
154
  failure_when: Type.Optional(ConditionSchema),
155
+ raw: Type.Optional(Type.Boolean()),
156
+ ...HistoryField,
157
+ ...CursorFields,
102
158
  });
103
159
 
104
160
  const StatusActionParams = Type.Object({
@@ -106,8 +162,12 @@ const StatusActionParams = Type.Object({
106
162
  id: Type.Optional(Type.String()),
107
163
  status: Type.Optional(Type.Array(Type.String())),
108
164
  limit: Type.Optional(Type.Number()),
109
- tail_lines: Type.Optional(Type.Number()),
165
+ lines: Type.Optional(Type.Number({ description: "action:log trailing display rows (default 10; 0 pages raw bytes)." })),
166
+ tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines." })),
110
167
  verbose: Type.Optional(Type.Boolean()),
168
+ raw: Type.Optional(Type.Boolean()),
169
+ ...HistoryField,
170
+ ...CursorFields,
111
171
  });
112
172
 
113
173
  const BACKGROUND_ORCHESTRATION_GUIDELINES = [
@@ -169,45 +229,47 @@ export function registerTools(pi: ExtensionAPI): void {
169
229
  pi.registerTool({
170
230
  name: "bg_task_list",
171
231
  label: "BG List",
172
- description: "List durable background tasks. Nonblocking.",
232
+ description: "List durable background tasks as compact rows under a 1 KiB UTF-8 budget (10 entries default), newest first. Current-session only unless all:true. Nonblocking. Pass the returned nextCursor as cursor to page further (every task is reachable), or statusCursor to get a small no-change response or failure-only updates; a higher limit/max_bytes gives a larger explicit page.",
173
233
  parameters: ListParams,
174
- async execute(_toolCallId, params) {
175
- return text(formatList(resolveList(params.status, params.limit)));
234
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
235
+ activeSession = getCallbackOrigin(ctx);
236
+ return text(formatList(listOptions(params, activeSession)));
176
237
  },
177
238
  });
178
239
 
179
240
  pi.registerTool({
180
241
  name: "bg_task_status",
181
242
  label: "BG Status",
182
- description: "Inspect one background task. Default output is a compact model-facing summary; pass verbose:true only when full raw metadata is explicitly needed. After a terminal callback, call this first and call bg_task_log only if the summary is insufficient.",
183
- parameters: IdParams,
184
- async execute(_toolCallId, params) {
185
- const meta = readMeta(params.id);
186
- return text(formatStatus(meta, params.id, { verbose: params.verbose === true }));
243
+ description: "Inspect one background task. Default output is a compact model-facing summary (1 KiB UTF-8) with matched condition, exit/signal, stop error, and failure counts before progress. Current-session only unless all:true; unknown ownership is a gap, not missing or healthy. Pass verbose:true only when full raw metadata is explicitly needed. Environment values are omitted. A raw log nextCursor passed as cursor continues the raw log page; an incidentCursor pages incidents that need action; history:true lists failure history too. After a terminal callback, call this first and call bg_task_log only if the summary is insufficient.",
244
+ parameters: StatusParams,
245
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
246
+ activeSession = getCallbackOrigin(ctx);
247
+ return text(formatStatus(inspectMeta(params.id), statusOptions(params, activeSession)));
187
248
  },
188
249
  });
189
250
 
190
251
  pi.registerTool({
191
252
  name: "bg_task_log",
192
253
  label: "BG Log",
193
- description: "Read a background task log. Default output is a compact 5-line terminal-aware tail for model ingestion. Pass tail_lines for a bounded tail; tail_lines:0 returns the retained raw log, capped at 512 KiB for safe recovery. Nonblocking.",
254
+ description: "Read a background task log. Default output is a compact 10-line terminal-aware tail for model ingestion (1 KiB UTF-8). Current-session only unless all:true. Pass lines for a bounded tail (tail_lines is a deprecated alias); lines:0 pages the retained raw log from the oldest retained offset (16 KiB pages, 64 KiB hard cap) with a caller-owned cursor. Capture and retention loss are disclosed; this is not a full-history archive. Nonblocking.",
194
255
  parameters: LogParams,
195
256
  renderResult(result: unknown, options: unknown, theme: unknown) {
196
257
  return renderBackgroundTaskLogDisplay(result, options, theme);
197
258
  },
198
- async execute(_toolCallId, params) {
199
- return logText(params.id, params.tail_lines);
259
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
260
+ activeSession = ctx ? getCallbackOrigin(ctx) : activeSession;
261
+ return logText(params.id, logOptions(params, activeSession));
200
262
  },
201
263
  });
202
264
 
203
265
  pi.registerTool({
204
266
  name: "bg_task_stop",
205
267
  label: "BG Stop",
206
- 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.",
268
+ description: "Cancel a watcher or terminate a background task. Only a task owned by the current session stops; a foreign-session or unverifiable task is refused unless all:true. 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.",
207
269
  parameters: IdParams,
208
270
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
209
271
  activeSession = getCallbackOrigin(ctx);
210
- const result = await formatStop(pi, params.id, ctx, getActiveSession);
272
+ const result = await formatStop(pi, params.id, ctx, getActiveSession, statusOptions(params, activeSession));
211
273
  refreshBackgroundTasksNavigator(ctx);
212
274
  return text(result);
213
275
  },
@@ -216,7 +278,7 @@ export function registerTools(pi: ExtensionAPI): void {
216
278
  pi.registerTool({
217
279
  name: "bg_task",
218
280
  label: "BG Task",
219
- 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. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. 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.",
281
+ 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. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. 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 lines:0 to page retained raw bytes. Standalone tools and these wrappers share the same output assembler. List/status/log default to the current session; pass all:true to override. Stop and clear change only current-session tasks: clear dismisses every owned terminal task, or one task with id (all:true allows another session's task by id).",
220
282
  promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
221
283
  parameters: ActionParams,
222
284
  renderResult(result: unknown, options: unknown, theme: unknown) {
@@ -231,7 +293,7 @@ export function registerTools(pi: ExtensionAPI): void {
231
293
  pi.registerTool({
232
294
  name: "bg_status",
233
295
  label: "BG Status",
234
- description: "Action wrapper for inspecting background tasks: list, status, log, stop, or clear. Nonblocking. Status is compact by default; log returns a compact tail by default. Use verbose:true or tail_lines:0 only for explicit full-data recovery.",
296
+ description: "Action wrapper for inspecting background tasks: list, status, log, stop, or clear. Nonblocking. Status is compact by default; log returns a compact tail by default. Use verbose:true or lines:0 only for explicit full-data recovery. Shares the same output assembler as the standalone tools. Current-session default; pass all:true to override. Stop and clear change only current-session tasks (clear with id and all:true for another session's task).",
235
297
  parameters: StatusActionParams,
236
298
  renderResult(result: unknown, options: unknown, theme: unknown) {
237
299
  return renderBackgroundTaskLogDisplay(result, options, theme);
@@ -247,6 +309,47 @@ export function text(textValue: string, details?: unknown) {
247
309
  return { content: [{ type: "text" as const, text: textValue }], details };
248
310
  }
249
311
 
312
+ /** Requested byte budget: canonical `max_bytes`, else the deprecated `maxBytes` alias (#321). */
313
+ function requestedMaxBytes(params: Record<string, unknown>): number | undefined {
314
+ const value = readOutputControls(params).maxBytes;
315
+ return typeof value === "number" ? value : undefined;
316
+ }
317
+
318
+ function statusOptions(params: Record<string, unknown>, origin?: BackgroundTaskCallbackOrigin): OutputOptions {
319
+ return {
320
+ verbose: params.verbose === true,
321
+ history: params.history === true,
322
+ cursor: typeof params.cursor === "string" ? params.cursor : undefined,
323
+ maxBytes: requestedMaxBytes(params),
324
+ all: params.all === true,
325
+ ...scopeOptions(origin),
326
+ };
327
+ }
328
+
329
+ function logOptions(params: Record<string, unknown>, origin?: BackgroundTaskCallbackOrigin): OutputOptions {
330
+ // Canonical `lines`, else the deprecated `tail_lines` alias (#321).
331
+ const lines = readOutputControls(params).lines;
332
+ return {
333
+ tailLines: typeof lines === "number" ? lines : undefined,
334
+ raw: params.raw === true,
335
+ cursor: typeof params.cursor === "string" ? params.cursor : undefined,
336
+ maxBytes: requestedMaxBytes(params),
337
+ all: params.all === true,
338
+ ...scopeOptions(origin),
339
+ };
340
+ }
341
+
342
+ function listOptions(params: Record<string, unknown>, origin?: BackgroundTaskCallbackOrigin): OutputOptions {
343
+ return {
344
+ statuses: params.status as string[] | undefined,
345
+ limit: typeof params.limit === "number" ? params.limit : undefined,
346
+ cursor: typeof params.cursor === "string" ? params.cursor : undefined,
347
+ maxBytes: requestedMaxBytes(params),
348
+ all: params.all === true,
349
+ ...scopeOptions(origin),
350
+ };
351
+ }
352
+
250
353
  async function actionText(
251
354
  pi: ExtensionAPI,
252
355
  params: Record<string, unknown>,
@@ -255,7 +358,7 @@ async function actionText(
255
358
  getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
256
359
  ) {
257
360
  if (params.action === "log" && params.id) {
258
- return logText(String(params.id), params.tail_lines as number | undefined);
361
+ return logText(String(params.id), logOptions(params, callbackOrigin));
259
362
  }
260
363
  return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession));
261
364
  }
@@ -274,18 +377,18 @@ async function runAction(
274
377
  if (!params.success_when) return "Invalid parameters: watch requires success_when.";
275
378
  return withNavigatorRefresh(ctx, reportLaunch(() => startWatchTask(pi, params as never, ctx.cwd, callbackOrigin, getActiveSession)));
276
379
  case "list":
277
- return formatList(resolveList(params.status as string[] | undefined, params.limit as number | undefined));
380
+ return formatList(listOptions(params, callbackOrigin));
278
381
  case "status":
279
382
  if (!params.id) return "Invalid parameters: status requires id.";
280
- return formatStatus(readMeta(String(params.id)) ?? undefined, String(params.id), { verbose: params.verbose === true });
383
+ return formatStatus(inspectMeta(String(params.id)), statusOptions(params, callbackOrigin));
281
384
  case "log":
282
385
  if (!params.id) return "Invalid parameters: log requires id.";
283
- return formatLog(String(params.id), params.tail_lines as number | undefined);
386
+ return formatLog(String(params.id), logOptions(params, callbackOrigin));
284
387
  case "stop":
285
388
  if (!params.id) return "Invalid parameters: stop requires id.";
286
- return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession));
389
+ return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession, statusOptions(params, callbackOrigin)));
287
390
  case "clear":
288
- return withNavigatorRefresh(ctx, formatClear(params.status as string[] | undefined, callbackOrigin));
391
+ return withNavigatorRefresh(ctx, formatClear(params, statusOptions(params, callbackOrigin)));
289
392
  default:
290
393
  return `Unknown action: ${String(params.action)}`;
291
394
  }
@@ -311,111 +414,33 @@ function withNavigatorRefresh(ctx: ExtensionContext, result: string): string {
311
414
  return result;
312
415
  }
313
416
 
314
- function getCallbackOrigin(ctx: ExtensionContext): BackgroundTaskCallbackOrigin {
417
+ /** Origins whose session identity could not be read (the host threw). */
418
+ const unavailableSessionOrigins = new WeakSet<BackgroundTaskCallbackOrigin>();
419
+
420
+ function getCallbackOrigin(ctx: ExtensionContext | undefined): BackgroundTaskCallbackOrigin {
315
421
  let sessionId: string | undefined;
422
+ let unavailable = false;
316
423
  try {
317
- sessionId = ctx.sessionManager?.getSessionId();
424
+ sessionId = ctx?.sessionManager?.getSessionId();
318
425
  } catch {
319
426
  sessionId = undefined;
427
+ unavailable = true;
320
428
  }
321
- return { cwd: ctx.cwd, sessionId };
322
- }
323
-
324
- function resolveList(statuses?: string[], limit?: number): BackgroundTaskMeta[] {
325
- let metas = listMetas();
326
- if (statuses && statuses.length > 0) {
327
- const wanted = new Set(statuses);
328
- metas = metas.filter((meta) => wanted.has(meta.status));
329
- }
330
- return metas.slice(0, Math.max(1, Math.min(limit ?? 20, 100)));
331
- }
332
-
333
- export function formatLaunch(meta: BackgroundTaskMeta): string {
334
- const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
335
- const remote = meta.ssh
336
- ? ` Remote: ${meta.ssh.target}${meta.remote?.session ? ` mode=${meta.remote.session}` : ""}${meta.remote?.sessionName ? ` session=${meta.remote.sessionName}` : ""}.`
337
- : "";
338
- const setup = meta.remote?.bootstrapMessage ? ` Remote setup: ${meta.remote.bootstrapMessage}` : "";
339
- const warning = meta.remote?.warning ? ` Warning: ${meta.remote.warning}` : "";
340
- const failure = failureSummary(meta.id);
341
- return `${failure ? `${failure}\n` : ""}Started background ${meta.kind} ${label}. Status: ${meta.status}.${remote}${setup}${warning} Log: ${meta.logPath}`;
342
- }
343
-
344
- function formatList(metas: BackgroundTaskMeta[]): string {
345
- if (metas.length === 0) return "No background tasks found.";
346
- return metas.map((meta) => {
347
- const failure = failureSummary(meta.id);
348
- const age = formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt);
349
- const label = meta.name ? `${meta.name} ` : "";
350
- const remote = meta.ssh ? ` ${meta.ssh.target}${meta.remote?.session ? ` ${meta.remote.session}` : ""}` : "";
351
- return `${failure ? `${failure}\n` : ""}${meta.id} ${label}${meta.kind} ${meta.status} ${age}${remote}`;
352
- }).join("\n");
353
- }
354
-
355
- function formatStatus(meta: BackgroundTaskMeta | undefined, id?: string, options: { verbose?: boolean } = {}): string {
356
- if (!meta) return `No background task found${id ? ` for id ${id}` : ""}.`;
357
- const failure = failureSummary(meta.id);
358
- if (!options.verbose) return [failure, formatCompactStatus(meta)].filter(Boolean).join("\n");
359
- const state = readFailureState(failurePath(meta.id));
360
- const observations = Object.values(state.observations);
361
- return JSON.stringify(observations.length ? {
362
- failureSummary: failure, failureJournal: failurePath(meta.id),
363
- failureObservations: observations.map((observation) => ({ ...observation, attentionDeliveredAt: state.delivered[observation.id] })),
364
- ...meta,
365
- } : meta, null, 2);
366
- }
367
-
368
- function formatCompactStatus(meta: BackgroundTaskMeta): string {
369
- const lines = [
370
- `Background task ${meta.id}${meta.name ? ` (${meta.name})` : ""} is ${meta.status}.`,
371
- `kind: ${meta.kind}`,
372
- `elapsed: ${formatDuration((meta.endedAt ?? Date.now()) - meta.startedAt)}`,
373
- ];
374
- if (meta.ssh) lines.push(`remote: ${meta.ssh.target}`);
375
- if (meta.remote?.session) lines.push(`remote mode: ${meta.remote.session}`);
376
- if (meta.remote?.sessionName) lines.push(`remote session: ${meta.remote.sessionName}`);
377
- if (meta.remote?.bootstrapMessage) lines.push(`remote setup: ${oneLine(meta.remote.bootstrapMessage, 500)}`);
378
- if (meta.remote?.warning) lines.push(`warning: ${oneLine(meta.remote.warning, 500)}`);
379
- if (meta.remote?.stopMessage) lines.push(`remote stop: ${oneLine(meta.remote.stopMessage, 500)}`);
380
- if (meta.deadlineAt && meta.status === "running") lines.push(`deadline: ${formatDuration(meta.deadlineAt - Date.now())} left`);
381
- if (meta.lastExitCode !== undefined || meta.lastSignal !== undefined) lines.push(`last exit: ${meta.lastExitCode ?? "null"}${meta.lastSignal ? ` signal=${meta.lastSignal}` : ""}`);
382
- const reason = resultReason(meta.result);
383
- if (reason) lines.push(`result: ${reason}`);
384
- if (meta.error) lines.push(`error: ${oneLine(meta.error, 500)}`);
385
- if (meta.lastState !== undefined) lines.push(`last state: ${oneLine(meta.lastState, 800)}`);
386
- if (meta.logDiscardedBytes) lines.push(`log retention: ${meta.logDiscardedBytes} bytes discarded in ${meta.logRetentionEvents ?? 1} compaction(s).`);
387
- lines.push(`log: ${meta.logPath}`);
388
- lines.push(`For full metadata use bg_task_status id=${meta.id} verbose=true. For logs use bg_task_log id=${meta.id} tail_lines=5, or tail_lines=0 for the retained raw log.`);
389
- return lines.join("\n");
390
- }
391
-
392
- function resultReason(result: unknown): string | undefined {
393
- if (!result) return undefined;
394
- if (typeof result === "object" && result !== null && "reason" in result) {
395
- const reason = (result as { reason?: unknown }).reason;
396
- return reason === undefined ? undefined : oneLine(reason, 500);
397
- }
398
- return oneLine(result, 500);
399
- }
400
-
401
- function oneLine(value: unknown, maxLength: number): string {
402
- const raw = typeof value === "string" ? value : JSON.stringify(value);
403
- const single = String(raw ?? "").replace(/\s+/g, " ").trim();
404
- return single.length <= maxLength ? single : `${single.slice(0, Math.max(0, maxLength - 1))}…`;
429
+ const origin = { cwd: ctx?.cwd ?? "", sessionId };
430
+ if (unavailable) unavailableSessionOrigins.add(origin);
431
+ return origin;
405
432
  }
406
433
 
407
- function formatLog(id: string, tailLines?: number): string {
408
- const meta = readMeta(id);
409
- if (!meta) return `No background task found for id ${id}.`;
410
- const log = readLog(meta.logPath, tailLines ?? 5);
411
- const prefix = log.truncated ? `[showing tail of ${meta.logPath}]\n` : `[${meta.logPath}]\n`;
412
- const failure = failureSummary(id);
413
- return `${failure ? `${failure}\n` : ""}${prefix}${log.text || "(log is empty)"}`;
434
+ function scopeOptions(origin?: BackgroundTaskCallbackOrigin): Pick<OutputOptions, "origin" | "sessionUnavailable"> {
435
+ return {
436
+ origin,
437
+ ...(origin && unavailableSessionOrigins.has(origin) ? { sessionUnavailable: true } : {}),
438
+ };
414
439
  }
415
440
 
416
- function logText(id: string, tailLines?: number) {
417
- const body = formatLog(id, tailLines);
418
- if (!readMeta(id)) return text(body);
441
+ function logText(id: string, options: OutputOptions) {
442
+ const body = formatLog(id, options);
443
+ if (!inspectMeta(id).meta) return text(body);
419
444
  return text(body, buildBackgroundTaskLogDisplayDetails(body));
420
445
  }
421
446
 
@@ -464,8 +489,8 @@ export function renderBackgroundTaskLogDisplay(result: unknown, options: unknown
464
489
 
465
490
  function resultTextContent(result: unknown): string {
466
491
  const content = (result as { content?: Array<{ text?: string }> })?.content;
467
- if (!Array.isArray(content)) return String(result ?? "");
468
- return content.map((part) => part.text ?? "").join("\n");
492
+ if (Array.isArray(content)) return content.map((part) => part.text ?? "").join("\n");
493
+ return String(result ?? "");
469
494
  }
470
495
 
471
496
  function nonEmptyPreviewLines(lines: string[]): string[] {
@@ -519,46 +544,66 @@ async function formatStop(
519
544
  id: string,
520
545
  _ctx: ExtensionContext,
521
546
  getActiveSession?: () => BackgroundTaskCallbackOrigin | undefined,
547
+ options: OutputOptions = {},
522
548
  ): Promise<string> {
549
+ const existing = inspectMeta(id);
550
+ if (!existing.meta && !existing.found) return formatStopResult(existing, options);
551
+ if (!existing.meta) return formatMissingTask(existing, options);
552
+ // Same ownership rule as reads (#322): only a task owned by the current
553
+ // session stops unless the caller passes all:true.
554
+ const ownership = classifyOwnership(existing.meta, options);
555
+ if (ownership !== "allow") return formatMutationRefusal(id, ownership, "stop", options);
523
556
  const meta = await stopTask(pi, id, getActiveSession);
524
- if (!meta) return `No background task found for id ${id}.`;
525
- const remoteStop = meta.remote?.stopMessage ? ` ${meta.remote.stopMessage}` : "";
526
- const weakStop = meta.remote?.session === "direct" ? ` Warning: ${meta.remote.warning}` : "";
527
- return `Background task ${id} is ${meta.status}.${remoteStop}${weakStop}`;
557
+ if (!meta) return formatStopResult({ id, found: existing.found, readable: false, error: existing.error }, options);
558
+ return formatStopResult({ id: meta.id, meta, found: true, readable: true }, options);
528
559
  }
529
560
 
530
- function formatClear(statuses: string[] | undefined, active: BackgroundTaskCallbackOrigin): string {
561
+ /**
562
+ * Dismiss terminal tasks with the same ownership rule as reads (#322). Bulk
563
+ * clear never crosses the current session scope (stricter than reads, which
564
+ * accept all:true for a list); clear with an id honours all:true like a read
565
+ * by id. Tasks whose ownership cannot be verified are counted, not dismissed.
566
+ */
567
+ function formatClear(params: Record<string, unknown>, options: OutputOptions): string {
568
+ if (typeof params.id === "string" && params.id) return formatClearById(params.id, options);
569
+ const statuses = params.status as string[] | undefined;
531
570
  const wanted = statuses && statuses.length > 0 ? new Set(statuses) : undefined;
571
+ const statusLabel = wanted ? ` matching ${Array.from(wanted).join(",")}` : "";
572
+ const origin = options.origin;
573
+ if (!origin || options.sessionUnavailable) {
574
+ return "Dismissed 0 terminal background tasks: the current session identity is unavailable, so no task's ownership can be verified. Clear one task with id and all:true.";
575
+ }
576
+ const scoped: OutputOptions = { ...options, all: false };
532
577
  const now = Date.now();
533
578
  let cleared = 0;
534
- for (const meta of listMetasForOrigin(active)) {
579
+ let unverified = 0;
580
+ for (const meta of listMetasForOrigin(origin)) {
535
581
  if (meta.dismissedAt !== undefined) continue;
536
582
  if (!isTerminalStatus(meta.status)) continue;
537
583
  if (wanted && !wanted.has(meta.status)) continue;
538
- if (!belongsToActiveToolSession(meta, active)) continue;
584
+ if (classifyOwnership(meta, scoped) !== "allow") {
585
+ unverified += 1;
586
+ continue;
587
+ }
539
588
  meta.dismissedAt = now;
540
589
  writeMeta(meta);
541
590
  cleared += 1;
542
591
  }
543
- const statusLabel = wanted ? ` matching ${Array.from(wanted).join(",")}` : "";
544
- return `Dismissed ${cleared} terminal background task${cleared === 1 ? "" : "s"}${statusLabel}.`;
592
+ const skipped = unverified
593
+ ? ` ${unverified} terminal task${unverified === 1 ? "" : "s"} with unverifiable ownership ${unverified === 1 ? "was" : "were"} not dismissed; clear one with id and all:true.`
594
+ : "";
595
+ return `Dismissed ${cleared} terminal background task${cleared === 1 ? "" : "s"}${statusLabel}.${skipped}`;
545
596
  }
546
597
 
547
- function belongsToActiveToolSession(meta: BackgroundTaskMeta, active: BackgroundTaskCallbackOrigin): boolean {
548
- const origin = meta.callbackOrigin;
549
- if (origin) {
550
- if (origin.cwd !== active.cwd) return false;
551
- if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
552
- return true;
553
- }
554
- if (active.sessionId) return false;
555
- return meta.cwd === active.cwd;
598
+ function formatClearById(id: string, options: OutputOptions): string {
599
+ const inspection = inspectMeta(id);
600
+ if (!inspection.meta) return formatMissingTask(inspection, options);
601
+ const meta = inspection.meta;
602
+ const ownership = classifyOwnership(meta, options);
603
+ if (ownership !== "allow") return formatMutationRefusal(id, ownership, "clear", options);
604
+ if (!isTerminalStatus(meta.status)) return `Background task ${id} is ${meta.status}; only terminal tasks can be dismissed. Stop it first.`;
605
+ if (meta.dismissedAt !== undefined) return `Background task ${id} was already dismissed.`;
606
+ meta.dismissedAt = Date.now();
607
+ writeMeta(meta);
608
+ return `Dismissed terminal background task ${id} (${meta.status}).`;
556
609
  }
557
-
558
- function formatDuration(ms: number): string {
559
- const seconds = Math.max(0, Math.round(ms / 1000));
560
- if (seconds < 60) return `${seconds}s`;
561
- const minutes = Math.floor(seconds / 60);
562
- const rest = seconds % 60;
563
- return `${minutes}m${rest.toString().padStart(2, "0")}s`;
564
- }
package/src/types.ts CHANGED
@@ -34,6 +34,12 @@ export interface CommandResult {
34
34
  startedAt: number;
35
35
  endedAt: number;
36
36
  timedOut?: boolean;
37
+ /** Bytes of stdout never stored because the capture cap was reached. */
38
+ stdoutDiscardedBytes?: number;
39
+ /** Bytes of stderr never stored because the capture cap was reached. */
40
+ stderrDiscardedBytes?: number;
41
+ /** True when stdout or stderr capture overflowed. Distinct from log retention. */
42
+ captureTruncated?: boolean;
37
43
  }
38
44
 
39
45
  export interface RemoteTaskParams {
@@ -102,6 +108,13 @@ export interface BackgroundTaskMeta {
102
108
  maxLogBytes?: number;
103
109
  logDiscardedBytes?: number;
104
110
  logRetentionEvents?: number;
111
+ /** Incremented on every same-inode compaction so log-page cursors can detect it. */
112
+ logGeneration?: number;
113
+ /** Bytes never captured into watch/command buffers. Distinct from logDiscardedBytes. */
114
+ captureDiscardedBytes?: number;
115
+ captureOverflowEvents?: number;
116
+ stdoutDiscardedBytes?: number;
117
+ stderrDiscardedBytes?: number;
105
118
  pid?: number;
106
119
  /** Opaque process-start token used to reject recycled child PIDs. */
107
120
  pidStartTime?: string;
@@ -111,9 +124,18 @@ export interface BackgroundTaskMeta {
111
124
  spawnPidStartTime?: string;
112
125
  successWhen?: Condition;
113
126
  failureWhen?: Condition;
127
+ /**
128
+ * Caller-declared structured intent (#325), validated before launch. A later task of the same
129
+ * kind, cwd, SSH target, and session with the same operationId that succeeds recovers this
130
+ * task's earlier failures; exit codes in expectedExitCodes are recorded as expected failures.
131
+ */
132
+ operationId?: string;
133
+ expectedExitCodes?: number[];
114
134
  notifyOn?: "terminal";
115
135
  stopRequestedAt?: number;
116
136
  error?: string;
137
+ /** Set when stop failed while the task is still running. Distinct from poll errors. */
138
+ stopError?: string;
117
139
  ssh?: ResolvedSshIdentity;
118
140
  remote?: ResolvedRemoteTaskMetadata;
119
141
  }
@@ -122,6 +144,7 @@ export interface TerminalResult {
122
144
  status: Exclude<BackgroundTaskStatus, "running">;
123
145
  reason: string;
124
146
  matchedCondition?: Condition;
147
+ matchedValue?: unknown;
125
148
  commandResult?: CommandResult;
126
149
  }
127
150