pi-better-background-tasks 0.3.0 → 0.5.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
@@ -2,13 +2,17 @@ import { Type } from "typebox";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
4
4
  import {
5
+ classifyOwnership,
5
6
  formatLaunch,
6
7
  formatList,
7
8
  formatLog,
9
+ formatMissingTask,
10
+ formatMutationRefusal,
8
11
  formatStatus,
9
12
  formatStopResult,
10
13
  type OutputOptions,
11
14
  } from "./output.js";
15
+ import { readOutputControls } from "./shared-log-utils.js";
12
16
  import { cancelCallbackBatch } from "./shared-callback-batcher.js";
13
17
  import { inspectMeta, listMetasForOrigin, writeMeta } from "./registry.js";
14
18
  import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
@@ -32,13 +36,22 @@ const ConditionSchema = Type.Union([
32
36
  Type.Object({ type: Type.Literal("json_path_exists"), path: JsonPathSchema }),
33
37
  ]);
34
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
+
35
48
  const SshSchema = Type.Object({
36
49
  host: Type.String({ description: "SSH host. Required when ssh is set." }),
37
50
  user: Type.Optional(Type.String({ description: "SSH user." })),
38
51
  port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535, description: "SSH port." })),
39
52
  identity_file: Type.Optional(Type.String({ description: "SSH identity file path." })),
40
53
  jump: Type.Optional(Type.String({ description: "SSH jump host passed with -J." })),
41
- 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.")),
42
55
  }, {
43
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.",
44
57
  });
@@ -51,18 +64,29 @@ const RemoteSchema = Type.Object({
51
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.",
52
65
  });
53
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: 0, 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). 0 is allowed and ignored: exit 0 is already success. Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
75
+ };
76
+
54
77
  const CommandFields = {
55
78
  name: Type.Optional(Type.String({ description: "Human-readable task label." })),
56
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." })),
57
80
  argv: Type.Optional(Type.Array(Type.String(), { description: "Argument vector. Use with shell:false to avoid shell parsing." })),
58
81
  shell: Type.Optional(Type.Boolean({ description: "Run command through the package's bash-compatible shell. Default true." })),
59
82
  cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the current pi cwd." })),
60
- env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Extra environment variables." })),
83
+ env: Type.Optional(StringMap("Extra environment variables.")),
61
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." })),
62
85
  callback: Type.Optional(Type.Boolean({ description: "Queue a follow-up when the task reaches a terminal state. Default true." })),
63
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." })),
64
87
  ssh: Type.Optional(SshSchema),
65
88
  remote: Type.Optional(RemoteSchema),
89
+ ...IntentFields,
66
90
  };
67
91
 
68
92
  const SpawnParams = Type.Object(CommandFields);
@@ -76,8 +100,9 @@ const WatchParams = Type.Object({
76
100
 
77
101
  const CursorFields = {
78
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." })),
79
- 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." })),
80
- all: Type.Optional(Type.Boolean({ description: "Inspect or list tasks across every session. Default false is current-session only. Unknown ownership is reported as a gap, never as missing or healthy." })),
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." })),
81
106
  };
82
107
 
83
108
  const IdParams = Type.Object({
@@ -85,6 +110,15 @@ const IdParams = Type.Object({
85
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." })),
86
111
  ...CursorFields,
87
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,
121
+ });
88
122
  const ListParams = Type.Object({
89
123
  status: Type.Optional(Type.Array(Type.String({ description: "Statuses to include." }))),
90
124
  limit: Type.Optional(Type.Number({ description: "Maximum tasks to show. Default 10, max 100." })),
@@ -92,7 +126,8 @@ const ListParams = Type.Object({
92
126
  });
93
127
  const LogParams = Type.Object({
94
128
  id: Type.String({ description: "Background task id." }),
95
- tail_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." })),
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." })),
96
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." })),
97
132
  ...CursorFields,
98
133
  });
@@ -110,13 +145,15 @@ const ActionParams = Type.Object({
110
145
  id: Type.Optional(Type.String()),
111
146
  status: Type.Optional(Type.Array(Type.String())),
112
147
  limit: Type.Optional(Type.Number()),
113
- 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." })),
114
150
  verbose: Type.Optional(Type.Boolean()),
115
151
  ...CommandFields,
116
152
  interval_seconds: Type.Optional(Type.Number()),
117
153
  success_when: Type.Optional(ConditionSchema),
118
154
  failure_when: Type.Optional(ConditionSchema),
119
155
  raw: Type.Optional(Type.Boolean()),
156
+ ...HistoryField,
120
157
  ...CursorFields,
121
158
  });
122
159
 
@@ -125,9 +162,11 @@ const StatusActionParams = Type.Object({
125
162
  id: Type.Optional(Type.String()),
126
163
  status: Type.Optional(Type.Array(Type.String())),
127
164
  limit: Type.Optional(Type.Number()),
128
- 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." })),
129
167
  verbose: Type.Optional(Type.Boolean()),
130
168
  raw: Type.Optional(Type.Boolean()),
169
+ ...HistoryField,
131
170
  ...CursorFields,
132
171
  });
133
172
 
@@ -201,8 +240,8 @@ export function registerTools(pi: ExtensionAPI): void {
201
240
  pi.registerTool({
202
241
  name: "bg_task_status",
203
242
  label: "BG Status",
204
- 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. After a terminal callback, call this first and call bg_task_log only if the summary is insufficient.",
205
- parameters: IdParams,
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,
206
245
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
207
246
  activeSession = getCallbackOrigin(ctx);
208
247
  return text(formatStatus(inspectMeta(params.id), statusOptions(params, activeSession)));
@@ -212,7 +251,7 @@ export function registerTools(pi: ExtensionAPI): void {
212
251
  pi.registerTool({
213
252
  name: "bg_task_log",
214
253
  label: "BG Log",
215
- 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 tail_lines for a bounded tail; tail_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.",
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.",
216
255
  parameters: LogParams,
217
256
  renderResult(result: unknown, options: unknown, theme: unknown) {
218
257
  return renderBackgroundTaskLogDisplay(result, options, theme);
@@ -226,7 +265,7 @@ export function registerTools(pi: ExtensionAPI): void {
226
265
  pi.registerTool({
227
266
  name: "bg_task_stop",
228
267
  label: "BG Stop",
229
- 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.",
230
269
  parameters: IdParams,
231
270
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
232
271
  activeSession = getCallbackOrigin(ctx);
@@ -239,7 +278,7 @@ export function registerTools(pi: ExtensionAPI): void {
239
278
  pi.registerTool({
240
279
  name: "bg_task",
241
280
  label: "BG Task",
242
- 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 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.",
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).",
243
282
  promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
244
283
  parameters: ActionParams,
245
284
  renderResult(result: unknown, options: unknown, theme: unknown) {
@@ -254,7 +293,7 @@ export function registerTools(pi: ExtensionAPI): void {
254
293
  pi.registerTool({
255
294
  name: "bg_status",
256
295
  label: "BG Status",
257
- 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. Shares the same output assembler as the standalone tools. Current-session default; pass all:true to override.",
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).",
258
297
  parameters: StatusActionParams,
259
298
  renderResult(result: unknown, options: unknown, theme: unknown) {
260
299
  return renderBackgroundTaskLogDisplay(result, options, theme);
@@ -270,22 +309,31 @@ export function text(textValue: string, details?: unknown) {
270
309
  return { content: [{ type: "text" as const, text: textValue }], details };
271
310
  }
272
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
+
273
318
  function statusOptions(params: Record<string, unknown>, origin?: BackgroundTaskCallbackOrigin): OutputOptions {
274
319
  return {
275
320
  verbose: params.verbose === true,
321
+ history: params.history === true,
276
322
  cursor: typeof params.cursor === "string" ? params.cursor : undefined,
277
- maxBytes: typeof params.max_bytes === "number" ? params.max_bytes : undefined,
323
+ maxBytes: requestedMaxBytes(params),
278
324
  all: params.all === true,
279
325
  ...scopeOptions(origin),
280
326
  };
281
327
  }
282
328
 
283
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;
284
332
  return {
285
- tailLines: typeof params.tail_lines === "number" ? params.tail_lines : undefined,
333
+ tailLines: typeof lines === "number" ? lines : undefined,
286
334
  raw: params.raw === true,
287
335
  cursor: typeof params.cursor === "string" ? params.cursor : undefined,
288
- maxBytes: typeof params.max_bytes === "number" ? params.max_bytes : undefined,
336
+ maxBytes: requestedMaxBytes(params),
289
337
  all: params.all === true,
290
338
  ...scopeOptions(origin),
291
339
  };
@@ -296,7 +344,7 @@ function listOptions(params: Record<string, unknown>, origin?: BackgroundTaskCal
296
344
  statuses: params.status as string[] | undefined,
297
345
  limit: typeof params.limit === "number" ? params.limit : undefined,
298
346
  cursor: typeof params.cursor === "string" ? params.cursor : undefined,
299
- maxBytes: typeof params.max_bytes === "number" ? params.max_bytes : undefined,
347
+ maxBytes: requestedMaxBytes(params),
300
348
  all: params.all === true,
301
349
  ...scopeOptions(origin),
302
350
  };
@@ -340,7 +388,7 @@ async function runAction(
340
388
  if (!params.id) return "Invalid parameters: stop requires id.";
341
389
  return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession, statusOptions(params, callbackOrigin)));
342
390
  case "clear":
343
- return withNavigatorRefresh(ctx, formatClear(params.status as string[] | undefined, callbackOrigin));
391
+ return withNavigatorRefresh(ctx, formatClear(params, statusOptions(params, callbackOrigin)));
344
392
  default:
345
393
  return `Unknown action: ${String(params.action)}`;
346
394
  }
@@ -500,35 +548,62 @@ async function formatStop(
500
548
  ): Promise<string> {
501
549
  const existing = inspectMeta(id);
502
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);
503
556
  const meta = await stopTask(pi, id, getActiveSession);
504
557
  if (!meta) return formatStopResult({ id, found: existing.found, readable: false, error: existing.error }, options);
505
558
  return formatStopResult({ id: meta.id, meta, found: true, readable: true }, options);
506
559
  }
507
560
 
508
- 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;
509
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 };
510
577
  const now = Date.now();
511
578
  let cleared = 0;
512
- for (const meta of listMetasForOrigin(active)) {
579
+ let unverified = 0;
580
+ for (const meta of listMetasForOrigin(origin)) {
513
581
  if (meta.dismissedAt !== undefined) continue;
514
582
  if (!isTerminalStatus(meta.status)) continue;
515
583
  if (wanted && !wanted.has(meta.status)) continue;
516
- if (!belongsToActiveToolSession(meta, active)) continue;
584
+ if (classifyOwnership(meta, scoped) !== "allow") {
585
+ unverified += 1;
586
+ continue;
587
+ }
517
588
  meta.dismissedAt = now;
518
589
  writeMeta(meta);
519
590
  cleared += 1;
520
591
  }
521
- const statusLabel = wanted ? ` matching ${Array.from(wanted).join(",")}` : "";
522
- 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}`;
523
596
  }
524
597
 
525
- function belongsToActiveToolSession(meta: BackgroundTaskMeta, active: BackgroundTaskCallbackOrigin): boolean {
526
- const origin = meta.callbackOrigin;
527
- if (origin) {
528
- if (origin.cwd !== active.cwd) return false;
529
- if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
530
- return true;
531
- }
532
- if (active.sessionId) return false;
533
- 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}).`;
534
609
  }
package/src/types.ts CHANGED
@@ -124,6 +124,13 @@ export interface BackgroundTaskMeta {
124
124
  spawnPidStartTime?: string;
125
125
  successWhen?: Condition;
126
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[];
127
134
  notifyOn?: "terminal";
128
135
  stopRequestedAt?: number;
129
136
  error?: string;