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/README.md +46 -2
- package/package.json +1 -1
- package/src/failures.ts +101 -6
- package/src/index.ts +5 -0
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +4 -3
- package/src/output.ts +783 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +228 -33
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +358 -29
- package/src/shared-failure-observations.ts +833 -23
- package/src/shared-log-utils.ts +1139 -2
- package/src/shared-navigator.ts +12 -10
- package/src/shared-sandbox-core.ts +660 -17
- package/src/tools.ts +201 -156
- package/src/types.ts +23 -0
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 {
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
183
|
-
parameters:
|
|
184
|
-
async execute(_toolCallId, params) {
|
|
185
|
-
|
|
186
|
-
return text(formatStatus(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
380
|
+
return formatList(listOptions(params, callbackOrigin));
|
|
278
381
|
case "status":
|
|
279
382
|
if (!params.id) return "Invalid parameters: status requires id.";
|
|
280
|
-
return formatStatus(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
424
|
+
sessionId = ctx?.sessionManager?.getSessionId();
|
|
318
425
|
} catch {
|
|
319
426
|
sessionId = undefined;
|
|
427
|
+
unavailable = true;
|
|
320
428
|
}
|
|
321
|
-
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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,
|
|
417
|
-
const body = formatLog(id,
|
|
418
|
-
if (!
|
|
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 (
|
|
468
|
-
return
|
|
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
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
544
|
-
|
|
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
|
|
548
|
-
const
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
if (
|
|
555
|
-
|
|
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
|
|