pi-better-background-tasks 0.5.0 → 0.6.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 +51 -0
- package/package.json +1 -1
- package/src/output.ts +96 -7
- package/src/runtime.ts +164 -12
- package/src/sandbox.ts +3 -0
- package/src/shared-failure-observations.ts +22 -9
- package/src/shared-navigator.ts +57 -2
- package/src/shared-sandbox-core.ts +129 -21
- package/src/tools.ts +52 -10
- package/src/types.ts +20 -0
package/README.md
CHANGED
|
@@ -124,6 +124,57 @@ Missing JSON fields or invalid JSON output remain retryable; task status shows
|
|
|
124
124
|
the condition evaluation error until a subsequent poll recovers. Keep a finite
|
|
125
125
|
timeout to bound watches whose output never becomes evaluable.
|
|
126
126
|
|
|
127
|
+
### Writing a watch check
|
|
128
|
+
|
|
129
|
+
A check that swallows its own errors reports "still pending" forever. Keep
|
|
130
|
+
failures visible:
|
|
131
|
+
|
|
132
|
+
- Do not end the check with `exit 0` or `|| true`. A check that exits non-zero
|
|
133
|
+
is recorded as a failure and escalates to the parent session.
|
|
134
|
+
- Map an unknown or unparseable state to failure (a non-zero exit), not to
|
|
135
|
+
pending.
|
|
136
|
+
- Prefer structured output parsed with `jq -e` over hand-written format
|
|
137
|
+
strings. `jq -e` exits non-zero when the field is missing, so a broken query
|
|
138
|
+
shows up at once.
|
|
139
|
+
|
|
140
|
+
For example, a Cloud Run job execution:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
status=$(gcloud run jobs executions describe "$EXECUTION" --region="$REGION" --format=json \
|
|
144
|
+
| jq -er '.status.conditions[] | select(.type == "Completed") | .status') || exit 2
|
|
145
|
+
case "$status" in
|
|
146
|
+
True) echo TERMINAL_SUCCESS ;;
|
|
147
|
+
False) echo TERMINAL_FAILURE ;;
|
|
148
|
+
Unknown) echo STILL_RUNNING ;;
|
|
149
|
+
*) echo "unexpected Completed status: $status" >&2; exit 2 ;;
|
|
150
|
+
esac
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
with `success_when: {type: "stdout_contains", value: "TERMINAL_SUCCESS"}` and
|
|
154
|
+
`failure_when: {type: "stdout_contains", value: "TERMINAL_FAILURE"}`.
|
|
155
|
+
|
|
156
|
+
`bg_task_watch` (and `bg_task` with `action: "watch"`) waits up to 15 seconds
|
|
157
|
+
for the first check and puts its exit code, the newest few lines of stderr and
|
|
158
|
+
of stdout in the tool result, so a broken check is visible at launch. When the
|
|
159
|
+
result is short on room, stdout is cut first. If the first check is still
|
|
160
|
+
running after 15 seconds, or you press Esc during the wait, the result says so
|
|
161
|
+
at once and the watch continues.
|
|
162
|
+
|
|
163
|
+
A running watch also guards against a blind check. When 3 checks in a row exit
|
|
164
|
+
0, write to stderr, and match neither `success_when` nor `failure_when`, the
|
|
165
|
+
watch records one incident that needs action, with the latest stderr line, and
|
|
166
|
+
wakes the parent session once. The watch keeps running. A later check with
|
|
167
|
+
empty stderr recovers the incident whatever its exit code (a non-zero or
|
|
168
|
+
failed check is then recorded as its own incident), and so does a check that
|
|
169
|
+
matches a condition. A non-zero check that writes stderr restarts the count but
|
|
170
|
+
leaves the incident open. A clean pending check (exit 0, no stderr) never
|
|
171
|
+
counts.
|
|
172
|
+
|
|
173
|
+
Some tools write to stderr on success (`gcloud … list` prints "Listed 0
|
|
174
|
+
items.", and kubectl and npm print warnings), which can raise a false alarm.
|
|
175
|
+
If the stderr is expected, redirect it (`2>/dev/null`) or set
|
|
176
|
+
`blind_checks: 0`. Set `blind_checks` to another number to change the count.
|
|
177
|
+
|
|
127
178
|
## Install
|
|
128
179
|
|
|
129
180
|
```sh
|
package/package.json
CHANGED
package/src/output.ts
CHANGED
|
@@ -40,7 +40,10 @@ import {
|
|
|
40
40
|
import { failurePath } from "./failures.js";
|
|
41
41
|
import { captureGapsFor, pageTaskLog, readLog, type LogRead } from "./logs.js";
|
|
42
42
|
import { belongsToOrigin, inspectMeta, listTaskRecords, originOf, type MetaInspection } from "./registry.js";
|
|
43
|
-
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, Condition } from "./types.js";
|
|
43
|
+
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, Condition, FirstWatchCheck } from "./types.js";
|
|
44
|
+
|
|
45
|
+
/** The launch tool stopped waiting while a watch's first check was still running (#359). */
|
|
46
|
+
export type FirstCheckPending = { pending: "timeout" | "aborted" | "suspended"; waitedMs: number };
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
49
|
* Issue #312 consumer budgets. Defaults follow OUTPUT-POLICY / shared
|
|
@@ -240,9 +243,10 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
|
|
|
240
243
|
meta.captureDiscardedBytes ? `capture overflow discarded ${meta.captureDiscardedBytes} bytes; not full history` : undefined,
|
|
241
244
|
].filter((line): line is string => Boolean(line));
|
|
242
245
|
// History (expected and closed incidents) is not a row, but the callback still says it exists, so a
|
|
243
|
-
// declared expected exit is not read as a plain failure.
|
|
246
|
+
// declared expected exit is not read as a plain failure. It leads the decision: a tight callback
|
|
247
|
+
// budget keeps a prefix of the decision, and this short line must survive it (#332).
|
|
244
248
|
const history = rows.length ? historyTailLine(state) : quietFailureLine(state);
|
|
245
|
-
const decision = [formatDecision(meta), ...gapLines
|
|
249
|
+
const decision = [history, formatDecision(meta), ...gapLines].filter(Boolean).join("\n") || undefined;
|
|
246
250
|
return {
|
|
247
251
|
outcome: meta.status,
|
|
248
252
|
...(rows.length ? { failureRows: rows, incidentCount: rows.length } : {}),
|
|
@@ -434,24 +438,109 @@ function asInspection(inspection: MetaInspection | BackgroundTaskMeta | undefine
|
|
|
434
438
|
return { id: meta.id, meta, found: true, readable: true };
|
|
435
439
|
}
|
|
436
440
|
|
|
437
|
-
|
|
441
|
+
/**
|
|
442
|
+
* Launch result. For a watch, `firstCheck` reports its first check (#359): the check's result,
|
|
443
|
+
* or why the launch stopped waiting while it was still running.
|
|
444
|
+
*
|
|
445
|
+
* The first check gets whatever the status budget leaves after the rest of the launch text, so
|
|
446
|
+
* nothing else is clipped for it. The log path is kept whole or dropped whole: a clipped path
|
|
447
|
+
* looks valid but points nowhere.
|
|
448
|
+
*/
|
|
449
|
+
export function formatLaunch(meta: BackgroundTaskMeta, firstCheck?: FirstWatchCheck | FirstCheckPending): string {
|
|
438
450
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
439
451
|
const remoteLines = [
|
|
440
452
|
...(meta.ssh ? [`Remote: ${meta.ssh.target}${meta.remote?.session ? ` mode=${meta.remote.session}` : ""}${meta.remote?.sessionName ? ` session=${meta.remote.sessionName}` : ""}.`] : []),
|
|
441
453
|
...(meta.remote?.bootstrapMessage ? [`Remote setup: ${meta.remote.bootstrapMessage}`] : []),
|
|
442
454
|
...(meta.remote?.warning ? [`Warning: ${meta.remote.warning}`] : []),
|
|
443
455
|
];
|
|
444
|
-
|
|
456
|
+
const logLine = `Log: ${meta.logPath}`;
|
|
457
|
+
const build = (checkText: string | undefined, withLog: boolean) => assembleBackgroundContent({
|
|
445
458
|
surface: "status",
|
|
446
459
|
sections: {
|
|
447
460
|
identity: `Started background ${meta.kind} ${label}. Status: ${meta.status}.`,
|
|
448
461
|
failure: incidentSection(meta.id, {}),
|
|
449
462
|
decision: formatDecision(meta),
|
|
450
|
-
diagnostics: remoteLines.join("\n") || undefined,
|
|
451
|
-
progress:
|
|
463
|
+
diagnostics: [...remoteLines, ...(checkText ? [checkText] : [])].join("\n") || undefined,
|
|
464
|
+
progress: withLog ? logLine : undefined,
|
|
452
465
|
},
|
|
453
466
|
gaps: taskGaps(meta),
|
|
454
467
|
});
|
|
468
|
+
let checkText: string | undefined;
|
|
469
|
+
if (firstCheck) {
|
|
470
|
+
const room = backgroundBudget("status") - utf8ByteLength(build(undefined, true)) - 1;
|
|
471
|
+
checkText = formatFirstWatchCheck(meta, firstCheck, room);
|
|
472
|
+
}
|
|
473
|
+
const text = build(checkText, true);
|
|
474
|
+
return text.includes(logLine) ? text : build(checkText, false);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const FIRST_CHECK_TAIL_LINES = 3;
|
|
478
|
+
const FIRST_CHECK_LINE_CHARS = 200;
|
|
479
|
+
|
|
480
|
+
function tailLines(text: string): string[] {
|
|
481
|
+
return text.split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean)
|
|
482
|
+
.slice(-FIRST_CHECK_TAIL_LINES)
|
|
483
|
+
.map((line) => line.length > FIRST_CHECK_LINE_CHARS ? `${line.slice(0, FIRST_CHECK_LINE_CHARS - 1)}…` : line);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function pendingFirstCheckText(check: FirstCheckPending): string {
|
|
487
|
+
switch (check.pending) {
|
|
488
|
+
case "aborted":
|
|
489
|
+
return "First check still running; stopped waiting because the tool call was cancelled. The watch continues in the background; check it later with bg_task_status.";
|
|
490
|
+
case "suspended":
|
|
491
|
+
return "First check still running when the session shut down. The watch continues when its session resumes; check it later with bg_task_status.";
|
|
492
|
+
default:
|
|
493
|
+
return `First check still running after ${formatDuration(check.waitedMs)}; the watch continues in the background. Check it later with bg_task_status.`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The first check of a watch in at most `maxBytes` (#359). What matters most is kept first: the
|
|
499
|
+
* outcome, the exit-0-with-stderr warning, the newest stderr lines, then the newest stdout lines,
|
|
500
|
+
* so stdout is cut first. Lines are shown oldest to newest.
|
|
501
|
+
*/
|
|
502
|
+
export function formatFirstWatchCheck(meta: BackgroundTaskMeta, check: FirstWatchCheck | FirstCheckPending, maxBytes = Number.MAX_SAFE_INTEGER): string {
|
|
503
|
+
if ("pending" in check) return pendingFirstCheckText(check);
|
|
504
|
+
if (check.error) return `First check could not run: ${oneLine(check.error, 300)}`;
|
|
505
|
+
const outcome = check.timedOut ? "timed out" : check.signal ? `signal ${check.signal}` : `exit ${check.exitCode ?? "unknown"}`;
|
|
506
|
+
const took = check.durationMs < 1000 ? `${check.durationMs}ms` : formatDuration(check.durationMs);
|
|
507
|
+
const header = `First check: ${outcome} in ${took}.`;
|
|
508
|
+
const stdout = tailLines(check.stdout);
|
|
509
|
+
const stderr = tailLines(check.stderr);
|
|
510
|
+
const warning = check.exitCode === 0 && stderr.length && meta.status === "running"
|
|
511
|
+
? "The check exited 0 but wrote stderr: if it is broken, the watch cannot tell. Let errors exit non-zero."
|
|
512
|
+
: undefined;
|
|
513
|
+
const keptErr: string[] = [];
|
|
514
|
+
const keptOut: string[] = [];
|
|
515
|
+
let keptEmptyStdout = false;
|
|
516
|
+
const render = () => [
|
|
517
|
+
header,
|
|
518
|
+
...(warning ? [warning] : []),
|
|
519
|
+
...(keptErr.length ? [`stderr tail:\n${keptErr.map((line) => ` ${line}`).join("\n")}`] : []),
|
|
520
|
+
...(keptOut.length ? [`stdout tail:\n${keptOut.map((line) => ` ${line}`).join("\n")}`] : []),
|
|
521
|
+
...(keptEmptyStdout ? ["stdout: (empty)"] : []),
|
|
522
|
+
].join("\n");
|
|
523
|
+
const fits = () => utf8ByteLength(render()) <= maxBytes;
|
|
524
|
+
// Newest first within each stream; each kept line goes in front to stay in order.
|
|
525
|
+
for (const line of [...stderr].reverse()) {
|
|
526
|
+
keptErr.unshift(line);
|
|
527
|
+
if (!fits()) { keptErr.shift(); break; }
|
|
528
|
+
}
|
|
529
|
+
for (const line of [...stdout].reverse()) {
|
|
530
|
+
keptOut.unshift(line);
|
|
531
|
+
if (!fits()) { keptOut.shift(); break; }
|
|
532
|
+
}
|
|
533
|
+
if (!stdout.length) {
|
|
534
|
+
keptEmptyStdout = true;
|
|
535
|
+
if (!fits()) keptEmptyStdout = false;
|
|
536
|
+
}
|
|
537
|
+
const omitted = (stderr.length - keptErr.length) + (stdout.length - keptOut.length);
|
|
538
|
+
if (omitted > 0) {
|
|
539
|
+
const note = `(${omitted} output line${omitted === 1 ? "" : "s"} omitted; see bg_task_log)`;
|
|
540
|
+
const text = `${render()}\n${note}`;
|
|
541
|
+
if (utf8ByteLength(text) <= maxBytes) return text;
|
|
542
|
+
}
|
|
543
|
+
return render();
|
|
455
544
|
}
|
|
456
545
|
|
|
457
546
|
function redactedVerbose(meta: BackgroundTaskMeta): unknown {
|
package/src/runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
CommandResult,
|
|
20
20
|
CommandSpec,
|
|
21
21
|
Condition,
|
|
22
|
+
FirstWatchCheck,
|
|
22
23
|
RemoteTaskParams,
|
|
23
24
|
SshConnectionParams,
|
|
24
25
|
TerminalResult,
|
|
@@ -31,6 +32,8 @@ const processTimeoutTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
31
32
|
const activeProcessTimeouts = new Set<string>();
|
|
32
33
|
const activeRemoteTasks = new Map<string, ResolvedSshRemoteTask>();
|
|
33
34
|
const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
|
|
35
|
+
/** Tasks whose stop in this instance is waiting on an in-flight tmux start, and kills the session itself. */
|
|
36
|
+
const remoteStopsAwaitingStart = new Set<string>();
|
|
34
37
|
const activePolls = new Set<string>();
|
|
35
38
|
const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
36
39
|
const LOG_RETENTION_CHECK_MS = 1000;
|
|
@@ -44,6 +47,63 @@ let scheduledWorkSuspended = false;
|
|
|
44
47
|
const REMOTE_SESSION_POLL_MS = 100;
|
|
45
48
|
|
|
46
49
|
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
50
|
+
/** Consecutive blind checks (exit 0, stderr, no condition matched) before a watch is flagged (#359). */
|
|
51
|
+
export const DEFAULT_BLIND_CHECKS = 3;
|
|
52
|
+
/** How long bg_task_watch waits for the first check before returning (#359). */
|
|
53
|
+
export const FIRST_WATCH_CHECK_WAIT_MS = 15_000;
|
|
54
|
+
const BLIND_OPERATION = "watch-blind";
|
|
55
|
+
/** How to fix a false alarm: some tools write progress or warnings to stderr on success. */
|
|
56
|
+
export const BLIND_CHECK_HINT = "If the stderr is expected (progress or warnings), redirect it (2>/dev/null) or set blind_checks:0.";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why the launch stopped waiting before the first check finished (#359): the bounded wait
|
|
60
|
+
* ran out, the tool call was aborted (Esc), or the session shut down.
|
|
61
|
+
*/
|
|
62
|
+
export type FirstCheckWaitEnd = "timeout" | "aborted" | "suspended";
|
|
63
|
+
/** A first check's outcome, or why it is not known yet; undefined when the watch ended without one. */
|
|
64
|
+
export type FirstCheckOutcome = FirstWatchCheck | { pending: FirstCheckWaitEnd; waitedMs: number } | undefined;
|
|
65
|
+
|
|
66
|
+
/** In-flight first checks of watches launched by this instance, keyed by task id. */
|
|
67
|
+
const firstCheckWaiters = new Map<string, { promise: Promise<FirstWatchCheck | "suspended" | undefined>; resolve: (check: FirstWatchCheck | "suspended" | undefined) => void }>();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wait for a watch's first check, bounded by `timeoutMs` and `signal` (#359). Resolves a pending
|
|
71
|
+
* outcome when the wait ends first, and undefined when this instance did not launch the watch
|
|
72
|
+
* or the watch ended without a check.
|
|
73
|
+
*/
|
|
74
|
+
export async function awaitFirstWatchCheck(id: string, timeoutMs = FIRST_WATCH_CHECK_WAIT_MS, signal?: AbortSignal): Promise<FirstCheckOutcome> {
|
|
75
|
+
const waiter = firstCheckWaiters.get(id);
|
|
76
|
+
if (!waiter) return undefined;
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
const pending = (reason: FirstCheckWaitEnd) => ({ pending: reason, waitedMs: Date.now() - started });
|
|
79
|
+
if (signal?.aborted) return pending("aborted");
|
|
80
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
let onAbort: (() => void) | undefined;
|
|
82
|
+
const timeout = new Promise<"timeout">((resolve) => {
|
|
83
|
+
// Kept referenced: the launching tool call is waiting on it, and the watch timers are
|
|
84
|
+
// unref'd, so an unref'd wait could let the event loop drain before the first check.
|
|
85
|
+
timer = setTimeout(() => resolve("timeout"), Math.max(0, timeoutMs));
|
|
86
|
+
});
|
|
87
|
+
const aborted = new Promise<"aborted">((resolve) => {
|
|
88
|
+
onAbort = () => resolve("aborted");
|
|
89
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const outcome = await Promise.race([waiter.promise, timeout, aborted]);
|
|
93
|
+
if (outcome === "timeout" || outcome === "aborted" || outcome === "suspended") return pending(outcome);
|
|
94
|
+
return outcome;
|
|
95
|
+
} finally {
|
|
96
|
+
if (timer) clearTimeout(timer);
|
|
97
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function settleFirstCheck(id: string, check: FirstWatchCheck | "suspended" | undefined): void {
|
|
102
|
+
const waiter = firstCheckWaiters.get(id);
|
|
103
|
+
if (!waiter) return;
|
|
104
|
+
firstCheckWaiters.delete(id);
|
|
105
|
+
waiter.resolve(check);
|
|
106
|
+
}
|
|
47
107
|
|
|
48
108
|
/**
|
|
49
109
|
* Stop this extension instance's scheduled work when its session shuts down (#324).
|
|
@@ -81,6 +141,8 @@ export function suspendScheduledWork(): void {
|
|
|
81
141
|
remoteSessionTimers.clear();
|
|
82
142
|
processTimeoutTimers.clear();
|
|
83
143
|
logRetentionTimers.clear();
|
|
144
|
+
// A launch still waiting on a first check that will not run here reports it as still running.
|
|
145
|
+
for (const id of [...firstCheckWaiters.keys()]) settleFirstCheck(id, "suspended");
|
|
84
146
|
suspendFailureAttention();
|
|
85
147
|
}
|
|
86
148
|
|
|
@@ -94,12 +156,16 @@ export function resumeScheduledWork(): void {
|
|
|
94
156
|
|
|
95
157
|
export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
|
|
96
158
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
/**
|
|
159
|
+
/** Structured intent (#325), declared by the launching agent and shared by spawns and watchers. */
|
|
160
|
+
export interface TaskIntentParams {
|
|
161
|
+
/** Stable id shared by modified retries of one operation. */
|
|
100
162
|
operation_id?: string | null;
|
|
101
|
-
/**
|
|
163
|
+
/** Non-zero exit codes declared intentional before launch. */
|
|
102
164
|
expected_exit_codes?: number[] | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SpawnTaskParams extends CommandSpec, TaskIntentParams {
|
|
168
|
+
name?: string;
|
|
103
169
|
callback?: boolean;
|
|
104
170
|
timeout_seconds?: number;
|
|
105
171
|
max_log_bytes?: number;
|
|
@@ -107,18 +173,16 @@ export interface SpawnTaskParams extends CommandSpec {
|
|
|
107
173
|
remote?: RemoteTaskParams;
|
|
108
174
|
}
|
|
109
175
|
|
|
110
|
-
export interface WatchTaskParams extends CommandSpec {
|
|
176
|
+
export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
|
|
111
177
|
name?: string;
|
|
112
|
-
/** Structured intent (#325): stable id shared by modified retries of one operation. */
|
|
113
|
-
operation_id?: string | null;
|
|
114
|
-
/** Structured intent (#325): non-zero exit codes declared intentional before launch. */
|
|
115
|
-
expected_exit_codes?: number[] | null;
|
|
116
178
|
callback?: boolean;
|
|
117
179
|
interval_seconds?: number;
|
|
118
180
|
timeout_seconds?: number;
|
|
119
181
|
max_log_bytes?: number;
|
|
120
182
|
success_when: Condition;
|
|
121
183
|
failure_when?: Condition;
|
|
184
|
+
/** Consecutive blind checks before the watch is flagged (#359). Default 3; 0 turns it off. */
|
|
185
|
+
blind_checks?: number;
|
|
122
186
|
ssh?: SshConnectionParams;
|
|
123
187
|
remote?: RemoteTaskParams;
|
|
124
188
|
}
|
|
@@ -157,9 +221,10 @@ export function spawnTask(
|
|
|
157
221
|
}, dependencies.remoteRunner)
|
|
158
222
|
: undefined;
|
|
159
223
|
const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
|
|
224
|
+
const sandboxNotices: string[] = [];
|
|
160
225
|
const launchSpec = remoteTask
|
|
161
226
|
? commandSpec
|
|
162
|
-
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
|
|
227
|
+
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id), {}, (line) => sandboxNotices.push(line));
|
|
163
228
|
const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
|
|
164
229
|
// Outside project = Write or Write & delete can change files outside the
|
|
165
230
|
// project: start an APFS local snapshot (macOS, background, never blocks).
|
|
@@ -169,6 +234,9 @@ export function spawnTask(
|
|
|
169
234
|
: remoteTask
|
|
170
235
|
? remoteTask.spawn(logPath, true)
|
|
171
236
|
: spawnCommand(launchSpec, logPath, true);
|
|
237
|
+
for (const line of sandboxNotices) {
|
|
238
|
+
try { appendLine(logPath, `--- ${line} ---`); } catch { /* log gone */ }
|
|
239
|
+
}
|
|
172
240
|
if (snapshot?.started) {
|
|
173
241
|
void snapshot.done.then((outcome) => {
|
|
174
242
|
if (!outcome.ok) {
|
|
@@ -276,7 +344,15 @@ async function launchRemoteTmux(
|
|
|
276
344
|
if (remoteSessionStarts.get(id) === startAttempt) remoteSessionStarts.delete(id);
|
|
277
345
|
}
|
|
278
346
|
const afterStart = readMeta(id);
|
|
279
|
-
if (!afterStart
|
|
347
|
+
if (!afterStart) return;
|
|
348
|
+
if (afterStart.status !== "running" || afterStart.stopRequestedAt) {
|
|
349
|
+
// Stopped (or timed out) while the start was in flight. A stop in this instance awaited the
|
|
350
|
+
// attempt and kills the session itself. Anything else could not see the attempt: a stop from
|
|
351
|
+
// an instance loaded by /reload, or a deadline that found the session not yet started. Only
|
|
352
|
+
// this launch can still reach the session it may just have created, so it kills it (#332).
|
|
353
|
+
if (!remoteStopsAwaitingStart.has(id)) await killSessionStartedAfterStop(afterStart, remoteTask);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
280
356
|
if (started.exitCode !== 0) {
|
|
281
357
|
const detail = started.stderr.trim() || started.stdout.trim() || "remote tmux returned no diagnostic";
|
|
282
358
|
const reason = `Could not create remote tmux session ${afterStart.remote?.sessionName} on ${afterStart.ssh?.target} (exit ${started.exitCode ?? "unknown"}): ${detail}`;
|
|
@@ -296,6 +372,19 @@ async function launchRemoteTmux(
|
|
|
296
372
|
}
|
|
297
373
|
}
|
|
298
374
|
|
|
375
|
+
/** Kill a tmux session whose start completed after its task stopped; a failed start may still have left it running. */
|
|
376
|
+
async function killSessionStartedAfterStop(meta: BackgroundTaskMeta, remoteTask: ResolvedSshRemoteTask): Promise<void> {
|
|
377
|
+
const session = `remote tmux session ${meta.remote?.sessionName} on ${meta.ssh?.target}`;
|
|
378
|
+
try {
|
|
379
|
+
const stopped = await remoteTask.killTmuxSession();
|
|
380
|
+
appendLine(meta.logPath, stopped.exitCode === 0
|
|
381
|
+
? `--- Killed ${session}: it started after the task was ${meta.status === "running" ? "stopped" : meta.status} ---`
|
|
382
|
+
: `--- Could not kill ${session} that started after the task stopped (exit ${stopped.exitCode ?? "unknown"}) ---`);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
appendLine(meta.logPath, `--- Could not kill ${session} that started after the task stopped: ${error instanceof Error ? error.message : String(error)} ---`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
299
388
|
function scheduleRemoteSessionPoll(
|
|
300
389
|
pi: ExtensionAPI,
|
|
301
390
|
id: string,
|
|
@@ -404,6 +493,7 @@ export function startWatchTask(
|
|
|
404
493
|
const error = condition && validateCondition(condition);
|
|
405
494
|
if (error) throw new Error(`${name}: ${error}`);
|
|
406
495
|
}
|
|
496
|
+
const blindChecks = readBlindChecks(params.blind_checks);
|
|
407
497
|
const intent = readTaskIntent(params);
|
|
408
498
|
const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
|
|
409
499
|
const id = nextTaskId();
|
|
@@ -421,9 +511,10 @@ export function startWatchTask(
|
|
|
421
511
|
}, dependencies.remoteRunner)
|
|
422
512
|
: undefined;
|
|
423
513
|
const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
|
|
514
|
+
const sandboxNotices: string[] = [];
|
|
424
515
|
const launchSpec = remoteTask
|
|
425
516
|
? commandSpec
|
|
426
|
-
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
|
|
517
|
+
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id), {}, (line) => sandboxNotices.push(line));
|
|
427
518
|
const meta: BackgroundTaskMeta = {
|
|
428
519
|
id,
|
|
429
520
|
name: params.name,
|
|
@@ -447,6 +538,7 @@ export function startWatchTask(
|
|
|
447
538
|
spawnPidStartTime: currentProcessStartToken(),
|
|
448
539
|
successWhen: params.success_when,
|
|
449
540
|
failureWhen: params.failure_when,
|
|
541
|
+
...(blindChecks !== undefined ? { blindChecks } : {}),
|
|
450
542
|
notifyOn: "terminal",
|
|
451
543
|
ssh: remoteTask?.metadata.ssh,
|
|
452
544
|
remote: remoteTask?.metadata.remote,
|
|
@@ -454,13 +546,25 @@ export function startWatchTask(
|
|
|
454
546
|
};
|
|
455
547
|
ensureTaskDir(id);
|
|
456
548
|
appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
|
|
549
|
+
for (const line of sandboxNotices) appendLine(meta.logPath, `--- ${line} ---`);
|
|
457
550
|
writeMeta(meta);
|
|
551
|
+
let resolveFirst!: (check: FirstWatchCheck | "suspended" | undefined) => void;
|
|
552
|
+
const firstCheck = new Promise<FirstWatchCheck | "suspended" | undefined>((resolve) => { resolveFirst = resolve; });
|
|
553
|
+
firstCheckWaiters.set(id, { promise: firstCheck, resolve: resolveFirst });
|
|
458
554
|
scheduleWatch(pi, id, 0, getActiveSession, remoteTask
|
|
459
555
|
? (timeoutMs) => remoteTask.runOnce(undefined, timeoutMs)
|
|
460
556
|
: undefined);
|
|
461
557
|
return meta;
|
|
462
558
|
}
|
|
463
559
|
|
|
560
|
+
function readBlindChecks(value: unknown): number | undefined {
|
|
561
|
+
if (value === undefined || value === null) return undefined;
|
|
562
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
563
|
+
throw new Error("blind_checks must be a non-negative integer (0 turns the blind-check rule off).");
|
|
564
|
+
}
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
|
|
464
568
|
function resolveWatchTimeoutSeconds(timeoutSeconds: number | undefined): number | undefined {
|
|
465
569
|
if (timeoutSeconds === undefined) return DEFAULT_WATCH_TIMEOUT_SECONDS;
|
|
466
570
|
if (timeoutSeconds <= 0) return undefined;
|
|
@@ -641,7 +745,9 @@ export async function stopTask(
|
|
|
641
745
|
const remoteSessionMayExist = remote?.session === "tmux"
|
|
642
746
|
&& (remote.sessionStarted !== false || remoteStartAttempt !== undefined);
|
|
643
747
|
if (remoteStartAttempt) {
|
|
748
|
+
remoteStopsAwaitingStart.add(id);
|
|
644
749
|
try { await remoteStartAttempt; } catch { /* A failed SSH result can still leave the detached session running. */ }
|
|
750
|
+
finally { remoteStopsAwaitingStart.delete(id); }
|
|
645
751
|
}
|
|
646
752
|
|
|
647
753
|
if (remoteSessionMayExist) {
|
|
@@ -729,6 +835,7 @@ async function pollWatch(
|
|
|
729
835
|
): Promise<void> {
|
|
730
836
|
if (activePolls.has(id)) return;
|
|
731
837
|
activePolls.add(id);
|
|
838
|
+
let checked: FirstWatchCheck | undefined;
|
|
732
839
|
try {
|
|
733
840
|
const meta = readMeta(id);
|
|
734
841
|
if (!meta || meta.status !== "running" || meta.kind !== "command_watch") return;
|
|
@@ -741,6 +848,10 @@ async function pollWatch(
|
|
|
741
848
|
const result = runOnce
|
|
742
849
|
? await runOnce(timeoutMs)
|
|
743
850
|
: await runCommandOnce(commandSpecFromMeta(meta), undefined, timeoutMs);
|
|
851
|
+
checked = {
|
|
852
|
+
exitCode: result.exitCode, signal: result.signal, durationMs: Math.max(0, result.endedAt - result.startedAt),
|
|
853
|
+
...(result.timedOut ? { timedOut: true } : {}), stdout: result.stdout, stderr: result.stderr,
|
|
854
|
+
};
|
|
744
855
|
// A poll that was in flight when the session shut down belongs to a stale instance.
|
|
745
856
|
if (scheduledWorkSuspended) return;
|
|
746
857
|
appendWatchResult(meta.logPath, result);
|
|
@@ -793,6 +904,10 @@ async function pollWatch(
|
|
|
793
904
|
else if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
|
|
794
905
|
{ expected: expectedPollExit, at: result.endedAt });
|
|
795
906
|
else recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
|
|
907
|
+
observeBlindCheck(latest, result, pollKey, {
|
|
908
|
+
clean: !transportFailure && !conditionErrors.length,
|
|
909
|
+
matched: failure?.matched === true || success?.matched === true,
|
|
910
|
+
});
|
|
796
911
|
if (conditionErrors.length) latest.error = conditionErrors.join("; ");
|
|
797
912
|
if (failure?.matched) {
|
|
798
913
|
recordFailure(latest, "failure_when", "failure condition matched", pollKey, { category: "condition", at: result.endedAt });
|
|
@@ -840,10 +955,47 @@ async function pollWatch(
|
|
|
840
955
|
}
|
|
841
956
|
recordFailure(meta, "watch-poll", reason, `throw:${meta.lastCheckedAt ?? meta.startedAt}`, { category: meta.ssh ? "ssh" : "execution" });
|
|
842
957
|
finalize(meta, { status: "failed", reason }, pi, getActiveSession);
|
|
958
|
+
checked ??= { exitCode: null, signal: null, durationMs: 0, stdout: "", stderr: "", error: reason };
|
|
843
959
|
}
|
|
844
960
|
} finally {
|
|
845
961
|
activePolls.delete(id);
|
|
962
|
+
if (firstCheckWaiters.has(id)) {
|
|
963
|
+
if (checked) settleFirstCheck(id, checked);
|
|
964
|
+
else if (readMeta(id)?.status !== "running") settleFirstCheck(id, undefined);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Blind-check rule (#359). A check that exits 0, writes stderr, and matches neither condition
|
|
971
|
+
* is probably broken: it prints an error and then reports "still pending" forever (the real
|
|
972
|
+
* incident was a gcloud --format error followed by `exit 0`). After `blindChecks` such checks
|
|
973
|
+
* in a row, record one actionable incident; the existing attention path wakes the parent once.
|
|
974
|
+
* The watch keeps running. A later check with empty stderr, whatever its exit code (including a
|
|
975
|
+
* non-zero, SSH-transport-failed or condition-error check), or one matching a condition,
|
|
976
|
+
* recovers it; those failures are recorded as their own incidents. Any other check (non-zero
|
|
977
|
+
* with stderr) resets the count and leaves the incident open. A clean pending check (exit 0,
|
|
978
|
+
* no stderr) never counts.
|
|
979
|
+
*/
|
|
980
|
+
function observeBlindCheck(meta: BackgroundTaskMeta, result: CommandResult, pollKey: unknown,
|
|
981
|
+
check: { clean: boolean; matched: boolean }): void {
|
|
982
|
+
const threshold = meta.blindChecks ?? DEFAULT_BLIND_CHECKS;
|
|
983
|
+
const stderr = result.stderr.trim();
|
|
984
|
+
const blind = threshold > 0 && check.clean && !check.matched && result.exitCode === 0 && stderr.length > 0;
|
|
985
|
+
if (!blind) {
|
|
986
|
+
meta.blindCheckStreak = 0;
|
|
987
|
+
if (!stderr || check.matched) recoverFailure(meta, BLIND_OPERATION, pollKey, result.endedAt);
|
|
988
|
+
return;
|
|
846
989
|
}
|
|
990
|
+
const streak = (meta.blindCheckStreak ?? 0) + 1;
|
|
991
|
+
meta.blindCheckStreak = streak;
|
|
992
|
+
if (streak !== threshold) return;
|
|
993
|
+
const lastLine = stderr.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) ?? "";
|
|
994
|
+
const clipped = lastLine.length > 240 ? `${lastLine.slice(0, 239)}…` : lastLine;
|
|
995
|
+
// The summary is capped in compact rows; the stderr line rides as evidence, which rows show whole.
|
|
996
|
+
recordFailure(meta, BLIND_OPERATION,
|
|
997
|
+
`Blind watch check: ${streak} checks in a row exited 0 with stderr and matched no condition; the check may be broken. The watch keeps running.`,
|
|
998
|
+
pollKey, { category: "blind-check", at: result.endedAt, evidence: `latest stderr: ${clipped} · ${BLIND_CHECK_HINT}` });
|
|
847
999
|
}
|
|
848
1000
|
|
|
849
1001
|
function finalize(
|
package/src/sandbox.ts
CHANGED
|
@@ -279,6 +279,8 @@ export function confineCommandSpec(
|
|
|
279
279
|
plan: ForegroundSandboxPlan,
|
|
280
280
|
profilePath: string,
|
|
281
281
|
seams: SandboxSeams = {},
|
|
282
|
+
/** Receives lines to show with the launch (e.g. a placeholder left in the user's files). */
|
|
283
|
+
onNotice: (line: string) => void = () => {},
|
|
282
284
|
): CommandSpec {
|
|
283
285
|
if (!plan.confined) return spec;
|
|
284
286
|
|
|
@@ -331,6 +333,7 @@ export function confineCommandSpec(
|
|
|
331
333
|
throw blocked(plan, error instanceof Error ? error.message : String(error));
|
|
332
334
|
}
|
|
333
335
|
if (!command) throw blocked(plan, "no sandbox backend was applied");
|
|
336
|
+
for (const line of command.notices ?? []) onNotice(line);
|
|
334
337
|
|
|
335
338
|
return { ...spec, argv: [command.file, ...command.fileArgs], shell: false };
|
|
336
339
|
}
|
|
@@ -75,7 +75,8 @@ export function failureIdentity(...parts: unknown[]): string {
|
|
|
75
75
|
return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
|
|
76
76
|
}
|
|
77
77
|
function text(value: string | undefined, fallback: string): string {
|
|
78
|
-
|
|
78
|
+
// The 400-unit cut can split a surrogate pair; a lone half would render as U+FFFD (#332).
|
|
79
|
+
return dropLoneSurrogates((value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400));
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** Agent tool attempts: the agent handles its own tool errors, so one failure is not yet actionable. */
|
|
@@ -517,6 +518,8 @@ export interface TerminalFailureParts {
|
|
|
517
518
|
rows: string[];
|
|
518
519
|
/** Counts and separate facts: earlier-reported, unclassified, expected, closed history, correctness. */
|
|
519
520
|
notes: string[];
|
|
521
|
+
/** The unclassified-count note, when it is among `notes`: a byte budget keeps it longer than the others. */
|
|
522
|
+
unclassifiedNote?: string;
|
|
520
523
|
}
|
|
521
524
|
/**
|
|
522
525
|
* Terminal/completion facts: actionable and incomplete incidents from `incidents` as rows,
|
|
@@ -530,12 +533,14 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
|
|
|
530
533
|
const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
|
|
531
534
|
if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
|
|
532
535
|
const counts = failureCounts(state);
|
|
533
|
-
|
|
536
|
+
const unclassifiedNote = counts.unclassified
|
|
537
|
+
? `${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.` : undefined;
|
|
538
|
+
if (unclassifiedNote) notes.push(unclassifiedNote);
|
|
534
539
|
if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
|
|
535
540
|
const history = closedHistoryLine(state);
|
|
536
541
|
if (history) notes.push(history);
|
|
537
542
|
if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
|
|
538
|
-
return { rows, notes };
|
|
543
|
+
return { rows, notes, ...(unclassifiedNote ? { unclassifiedNote } : {}) };
|
|
539
544
|
}
|
|
540
545
|
export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
|
|
541
546
|
const { rows, notes } = terminalFailureParts(state, incidents);
|
|
@@ -643,7 +648,10 @@ export interface IncidentPageRequest extends IncidentRowOptions {
|
|
|
643
648
|
* Caller-owned incident pages. Whole rows are preferred; a row larger than the
|
|
644
649
|
* page is split at a code-point boundary and resumes at that byte, so pages
|
|
645
650
|
* reconstruct formatFailureLines() exactly (join with "\n" except after a page
|
|
646
|
-
* that `endsPartial`). A page never exceeds `maxBytes
|
|
651
|
+
* that `endsPartial`). A page never exceeds `maxBytes`, so one smaller than the
|
|
652
|
+
* next code point (up to 4 bytes) returns no text, `hasMore`, and a `nextCursor`
|
|
653
|
+
* equal to `cursor`: the caller retries with a larger page, as with
|
|
654
|
+
* pageVerbatimText. Consumers never ask for fewer than 4 bytes.
|
|
647
655
|
*/
|
|
648
656
|
export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
|
|
649
657
|
// A cursor carries its view: following a history cursor stays in history without the flag.
|
|
@@ -761,14 +769,19 @@ export function incidentVerbatimPage(state: FailureState, request: IncidentPageR
|
|
|
761
769
|
* it, so a cache keyed by it never serves stale incident counts.
|
|
762
770
|
*/
|
|
763
771
|
export function failureJournalFingerprint(path: string): string {
|
|
764
|
-
|
|
772
|
+
return `${fileChangeIdentity(path)}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* A file's identity and change stamp (device, inode, size, and nanosecond mtime/ctime), or its read
|
|
776
|
+
* error. Any append, rewrite, replacement, or deletion changes it; for cache keys, never for trust.
|
|
777
|
+
*/
|
|
778
|
+
export function fileChangeIdentity(path: string): string {
|
|
765
779
|
try {
|
|
766
780
|
const stats = statSync(path, { bigint: true });
|
|
767
|
-
|
|
781
|
+
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
|
|
768
782
|
} catch (error) {
|
|
769
|
-
|
|
783
|
+
return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
|
|
770
784
|
}
|
|
771
|
-
return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
772
785
|
}
|
|
773
786
|
|
|
774
787
|
export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
|
|
@@ -903,7 +916,7 @@ export function formatTerminalIncidentSummary(state: FailureState, options: Inci
|
|
|
903
916
|
// cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
|
|
904
917
|
// correctness note goes last, and only when it alone does not fit.
|
|
905
918
|
const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
|
|
906
|
-
const unclassified = notes.filter((x) => x
|
|
919
|
+
const unclassified = notes.filter((x) => x === parts.unclassifiedNote);
|
|
907
920
|
const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
|
|
908
921
|
const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
|
|
909
922
|
for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
|
package/src/shared-navigator.ts
CHANGED
|
@@ -95,6 +95,13 @@ type NavigatorState = {
|
|
|
95
95
|
editorComponent?: Component;
|
|
96
96
|
/** The editor Pi currently has mounted (the wrapper the factory returned last). */
|
|
97
97
|
editorFocusTarget?: unknown;
|
|
98
|
+
/** How many times that wrapper has rendered, so an overlay can tell whether it is still on screen. */
|
|
99
|
+
editorRenders?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Whether that wrapper rendered between the overlay's last two frames: true while it is on screen,
|
|
102
|
+
* including inside another extension's editor that wraps it. Undefined until two frames compare.
|
|
103
|
+
*/
|
|
104
|
+
editorRenderedLastFrame?: boolean;
|
|
98
105
|
detailOverlayRows?: number;
|
|
99
106
|
dispose?: () => void;
|
|
100
107
|
/** The mounted detail overlay, reused instead of stacking a second one. */
|
|
@@ -632,7 +639,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
632
639
|
|
|
633
640
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
634
641
|
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
635
|
-
const proxy = new Proxy(inner, {
|
|
642
|
+
const proxy: any = new Proxy(inner, {
|
|
636
643
|
get(target, prop) {
|
|
637
644
|
if (prop === "handleInput") {
|
|
638
645
|
return (data: string) => {
|
|
@@ -642,6 +649,13 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
|
642
649
|
target.handleInput(data);
|
|
643
650
|
};
|
|
644
651
|
}
|
|
652
|
+
if (prop === "render" && typeof target.render === "function") {
|
|
653
|
+
return (...args: unknown[]) => {
|
|
654
|
+
const s = state();
|
|
655
|
+
if (s.editorFocusTarget === proxy) s.editorRenders = (s.editorRenders ?? 0) + 1;
|
|
656
|
+
return target.render(...args);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
645
659
|
const value = Reflect.get(target, prop);
|
|
646
660
|
return typeof value === "function" ? value.bind(target) : value;
|
|
647
661
|
},
|
|
@@ -709,6 +723,19 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
709
723
|
return false;
|
|
710
724
|
}
|
|
711
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Whether pi-tui has the editor our wrapper returned mounted (#332). Pi mounts whatever the factory
|
|
728
|
+
* returns, so another extension's `setEditorComponent` (a foreign factory, or `undefined` for Pi's
|
|
729
|
+
* default editor) unmounts it, and no key reaches our input handler after that. `undefined` means
|
|
730
|
+
* the host cannot say (no `isComponentMounted`), and callers keep their earlier behaviour.
|
|
731
|
+
*/
|
|
732
|
+
function liveEditorMounted(tui: unknown): boolean | undefined {
|
|
733
|
+
const live = state().editorFocusTarget;
|
|
734
|
+
const check = (tui as { isComponentMounted?: (component: unknown) => boolean } | undefined)?.isComponentMounted;
|
|
735
|
+
if (!live || typeof check !== "function") return undefined;
|
|
736
|
+
try { return check.call(tui, live) === true; } catch { return undefined; }
|
|
737
|
+
}
|
|
738
|
+
|
|
712
739
|
/** Close a detail overlay that is still mounted while the editor has focus. */
|
|
713
740
|
function dismissOverlay(): void {
|
|
714
741
|
const s = state();
|
|
@@ -762,6 +789,7 @@ function openNavigator(): void {
|
|
|
762
789
|
try {
|
|
763
790
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
764
791
|
s.dispose = undefined;
|
|
792
|
+
s.editorRenderedLastFrame = undefined;
|
|
765
793
|
let disposeToken: (() => void) | undefined;
|
|
766
794
|
let overlayRef: NavigatorState["overlay"];
|
|
767
795
|
let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
|
|
@@ -774,8 +802,12 @@ function openNavigator(): void {
|
|
|
774
802
|
// Pi restores focus to the editor that was focused when the overlay opened. If an
|
|
775
803
|
// extension re-installed the editor since, that instance is unmounted and every key
|
|
776
804
|
// would be lost; hand focus to the live editor before closing.
|
|
805
|
+
// Only an editor on screen can take focus: after another extension replaced ours, Pi already
|
|
806
|
+
// focused its replacement, and handing focus to our unmounted wrapper would swallow every key.
|
|
807
|
+
// An extension that wraps ours mounts its own component, so pi-tui does not find ours, but ours
|
|
808
|
+
// still renders and handles every key through it: that is still the live editor (#332).
|
|
777
809
|
const liveEditor = s.editorFocusTarget;
|
|
778
|
-
if (liveEditor) {
|
|
810
|
+
if (liveEditor && (liveEditorMounted(tui) !== false || s.editorRenderedLastFrame !== false)) {
|
|
779
811
|
try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
|
|
780
812
|
}
|
|
781
813
|
done(value);
|
|
@@ -952,8 +984,31 @@ function createOverlayComponent(
|
|
|
952
984
|
requestRender();
|
|
953
985
|
}
|
|
954
986
|
|
|
987
|
+
let editorRendersSeen: number | undefined;
|
|
988
|
+
/**
|
|
989
|
+
* Our editor wrapper was uninstalled: close, since no key of ours can reach this overlay again and
|
|
990
|
+
* it would cover the editor that replaced ours (#332). An extension that composes the editor inside
|
|
991
|
+
* its own non-container component still renders ours, so the wrapper also counts as live while it
|
|
992
|
+
* rendered since the previous overlay frame (Pi renders the editor before it composites overlays).
|
|
993
|
+
*/
|
|
994
|
+
function closeIfEditorReplaced(): void {
|
|
995
|
+
const rendersBefore = editorRendersSeen;
|
|
996
|
+
const renders = state().editorRenders ?? 0;
|
|
997
|
+
editorRendersSeen = renders;
|
|
998
|
+
state().editorRenderedLastFrame = rendersBefore === undefined ? undefined : renders !== rendersBefore;
|
|
999
|
+
if (closed || liveEditorMounted(tui) !== false || rendersBefore === undefined || renders !== rendersBefore) return;
|
|
1000
|
+
// Not from inside pi-tui's render pass: hiding an overlay there would change the stack it is drawing.
|
|
1001
|
+
queueMicrotask(() => {
|
|
1002
|
+
if (closed) return;
|
|
1003
|
+
unfocusMainList();
|
|
1004
|
+
close();
|
|
1005
|
+
requestRender();
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
955
1009
|
return {
|
|
956
1010
|
render(width: number) {
|
|
1011
|
+
closeIfEditorReplaced();
|
|
957
1012
|
refreshRows();
|
|
958
1013
|
const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
959
1014
|
selectedId: selectedRow()?.navigatorId,
|
|
@@ -109,8 +109,12 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
109
109
|
internalHelperExecutable?: boolean;
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The wrapper command to spawn: the backend executable and its full argv.
|
|
114
|
+
* `notices` are lines the caller shows with the launch output, such as a
|
|
115
|
+
* placeholder the Linux backend had to leave in the user's files.
|
|
116
|
+
*/
|
|
117
|
+
export type SandboxCommand = { file: string; fileArgs: string[]; notices?: string[] };
|
|
114
118
|
|
|
115
119
|
/** The caller's default-on / explicit-request / opt-out decision. */
|
|
116
120
|
export type SandboxRequest = {
|
|
@@ -131,7 +135,11 @@ export type SandboxSeams = {
|
|
|
131
135
|
platform?: () => string;
|
|
132
136
|
/** Defaults to a PATH scan that stats and access-checks without executing. */
|
|
133
137
|
lookupExecutable?: (name: string) => string | undefined;
|
|
134
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Defaults to `fs.realpathSync.native`, which (unlike the JS `realpathSync`)
|
|
140
|
+
* returns the on-disk case of every existing component on a case-insensitive
|
|
141
|
+
* volume, as the kernel sees it. Must throw when the path does not exist.
|
|
142
|
+
*/
|
|
135
143
|
canonicalize?: (path: string) => string;
|
|
136
144
|
/** Defaults to `fs.writeFileSync`. */
|
|
137
145
|
writeProfile?: (path: string, contents: string) => void;
|
|
@@ -325,7 +333,7 @@ function currentPlatform(seams: SandboxSeams): string {
|
|
|
325
333
|
* canonicalizes through its real parent chain.
|
|
326
334
|
*/
|
|
327
335
|
export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
|
|
328
|
-
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
336
|
+
const canonicalize = seams.canonicalize ?? realpathSync.native;
|
|
329
337
|
const absolute = resolve(path);
|
|
330
338
|
try {
|
|
331
339
|
const resolved = canonicalize(absolute);
|
|
@@ -337,7 +345,7 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
337
345
|
try {
|
|
338
346
|
const source = statSync(resolved);
|
|
339
347
|
const alias = statSync(candidate);
|
|
340
|
-
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
348
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync.native(candidate);
|
|
341
349
|
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
342
350
|
}
|
|
343
351
|
return resolved;
|
|
@@ -358,7 +366,7 @@ function compile(
|
|
|
358
366
|
// bind-mounts it; the macOS backend has always tolerated a not-yet-created
|
|
359
367
|
// one. Keep both behaviors rather than unifying them here.
|
|
360
368
|
const writableRoot = strictRoot
|
|
361
|
-
? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
|
|
369
|
+
? (seams.canonicalize ?? realpathSync.native)(policy.writableRoot)
|
|
362
370
|
: canonicalizePath(policy.writableRoot, seams);
|
|
363
371
|
|
|
364
372
|
// The broad profile also keeps each literal entry: its removal grants would
|
|
@@ -544,6 +552,67 @@ function contains(root: string, target: string): boolean {
|
|
|
544
552
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
545
553
|
}
|
|
546
554
|
|
|
555
|
+
/** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
|
|
556
|
+
const caseFoldingByDevice = new Map<number, boolean>();
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Whether the volume holding `path` (its nearest existing ancestor) ignores
|
|
560
|
+
* case: an existing component whose case-swapped spelling names the same
|
|
561
|
+
* inode proves it. macOS only; false whenever it cannot be shown.
|
|
562
|
+
*/
|
|
563
|
+
function caseInsensitiveVolume(path: string): boolean {
|
|
564
|
+
if (process.platform !== "darwin") return false;
|
|
565
|
+
let current = path;
|
|
566
|
+
while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
|
|
567
|
+
let device: number;
|
|
568
|
+
try { device = statSync(current).dev; } catch { return false; }
|
|
569
|
+
const known = caseFoldingByDevice.get(device);
|
|
570
|
+
if (known !== undefined) return known;
|
|
571
|
+
for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
|
|
572
|
+
const name = basename(probe);
|
|
573
|
+
const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
|
|
574
|
+
if (swapped === name) continue;
|
|
575
|
+
try {
|
|
576
|
+
const original = statSync(probe);
|
|
577
|
+
if (original.dev !== device) break;
|
|
578
|
+
let folded = false;
|
|
579
|
+
try {
|
|
580
|
+
const other = statSync(join(dirname(probe), swapped));
|
|
581
|
+
folded = other.dev === original.dev && other.ino === original.ino;
|
|
582
|
+
} catch { /* The swapped name is absent: case-sensitive. */ }
|
|
583
|
+
caseFoldingByDevice.set(device, folded);
|
|
584
|
+
return folded;
|
|
585
|
+
} catch { return false; }
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Spell `path` the way the policy spells it when they differ only in case on a
|
|
592
|
+
* case-insensitive volume. `canonicalizePath` fixes the case of every existing
|
|
593
|
+
* component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
|
|
594
|
+
* the caller's case, and the kernel still treats it as the protected name. The
|
|
595
|
+
* longest policy path that matches case-insensitively wins, so the pre-check
|
|
596
|
+
* decides as the kernel does (#354).
|
|
597
|
+
*/
|
|
598
|
+
function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
|
|
599
|
+
const broad = policy.broad;
|
|
600
|
+
const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
|
|
601
|
+
...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
|
|
602
|
+
...(broad ? [broad.home, ...broad.tempRoots, ...broad.deleteRoots, ...broad.codePaths] : [])];
|
|
603
|
+
const lower = path.toLowerCase();
|
|
604
|
+
if (lower.length !== path.length) return path;
|
|
605
|
+
let best: string | undefined;
|
|
606
|
+
for (const candidate of candidates) {
|
|
607
|
+
const folded = candidate.toLowerCase();
|
|
608
|
+
if (folded.length !== candidate.length || !contains(folded, lower)) continue;
|
|
609
|
+
if (!best || candidate.length > best.length) best = candidate;
|
|
610
|
+
}
|
|
611
|
+
// Already spelled as the policy spells it: nothing to probe.
|
|
612
|
+
if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
|
|
613
|
+
return `${best}${path.slice(best.length)}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
547
616
|
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
548
617
|
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
549
618
|
}
|
|
@@ -558,7 +627,7 @@ export function evaluateReadAccess(
|
|
|
558
627
|
policy: CompiledSandboxWritePolicy,
|
|
559
628
|
seams: SandboxSeams = {},
|
|
560
629
|
): ReadAccessDecision {
|
|
561
|
-
const path = canonicalizePath(target, seams);
|
|
630
|
+
const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
|
|
562
631
|
const permissions = policy.permissions;
|
|
563
632
|
if (!permissions) return { allowed: true, path };
|
|
564
633
|
if (policy.broad) {
|
|
@@ -585,7 +654,7 @@ export function evaluateWriteAccess(
|
|
|
585
654
|
policy: CompiledSandboxWritePolicy,
|
|
586
655
|
seams: SandboxSeams = {},
|
|
587
656
|
): WriteAccessDecision {
|
|
588
|
-
const path = canonicalizePath(target, seams);
|
|
657
|
+
const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
|
|
589
658
|
if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
|
|
590
659
|
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
591
660
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
@@ -1370,10 +1439,28 @@ function buildLinuxBroadCommand(
|
|
|
1370
1439
|
const readOnly = new Set<string>();
|
|
1371
1440
|
const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
|
|
1372
1441
|
.some((name) => canonicalizePath(join(broad.home, name), seams) === path);
|
|
1442
|
+
// The literal fixed-list entry (a dangling `~/.ssh` link) or its canonical path.
|
|
1443
|
+
const directoryEntry = (path: string) => directoryLocation(path) ||
|
|
1444
|
+
[...DIRECTORY_LOCATIONS].some((name) => join(broad.home, name) === path);
|
|
1373
1445
|
// Directories holding a protected symlink: read-only, so the link cannot be
|
|
1374
1446
|
// replaced, with their existing entries bound writable again below.
|
|
1375
1447
|
const linkParents = new Set<string>();
|
|
1376
|
-
|
|
1448
|
+
// Where dangling links would land. Only the first missing component of each
|
|
1449
|
+
// needs a guard: nothing can be created below a read-only mount or a locked
|
|
1450
|
+
// parent. It is a directory when more of the path lies below it, or when the
|
|
1451
|
+
// link is a directory-type entry (`~/.ssh -> ~/work/ssh`).
|
|
1452
|
+
const firstMissing = new Map<string, boolean>();
|
|
1453
|
+
const belowFirstMissing = new Set<string>();
|
|
1454
|
+
for (const path of protectedPaths) {
|
|
1455
|
+
const unresolved = walkSymlinks(path).unresolved;
|
|
1456
|
+
if (!unresolved.length) continue;
|
|
1457
|
+
const first = unresolved[0]!;
|
|
1458
|
+
const full = unresolved.at(-1)!;
|
|
1459
|
+
firstMissing.set(first, firstMissing.get(first) === true || first !== full || directoryEntry(path));
|
|
1460
|
+
if (full !== first) belowFirstMissing.add(full);
|
|
1461
|
+
}
|
|
1462
|
+
const created: string[] = [];
|
|
1463
|
+
const makeDirectory = seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 }));
|
|
1377
1464
|
const guard = (path: string, hide: boolean) => {
|
|
1378
1465
|
let directory: boolean | undefined;
|
|
1379
1466
|
let link = false;
|
|
@@ -1387,21 +1474,39 @@ function buildLinuxBroadCommand(
|
|
|
1387
1474
|
if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
|
|
1388
1475
|
return;
|
|
1389
1476
|
}
|
|
1390
|
-
if (
|
|
1477
|
+
if (belowFirstMissing.has(path) && !firstMissing.has(path)) return; // Its first missing component is guarded.
|
|
1478
|
+
if (firstMissing.has(path)) {
|
|
1391
1479
|
// Where a dangling link would land: nothing may be created there.
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
// is
|
|
1480
|
+
// A missing directory gets an empty read-only placeholder directory,
|
|
1481
|
+
// so only that one name is taken, not every new entry beside it
|
|
1482
|
+
// (`~/.cache/<tool>/…` would otherwise lock all of `~/.cache`). A
|
|
1483
|
+
// missing file is not planted in the user's files: its parent is
|
|
1484
|
+
// locked instead (entries stay writable, see below), except in the
|
|
1485
|
+
// workspace root and temp, which must keep accepting new entries,
|
|
1486
|
+
// so the file placeholder is bound there. Each placeholder made is
|
|
1487
|
+
// reported, and the workspace and temp fail closed without one.
|
|
1396
1488
|
if (!writableAt(path)) return;
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1489
|
+
const ancestor = dirname(path);
|
|
1490
|
+
if (ancestor === broad.home) return; // Home is read-only already.
|
|
1491
|
+
const failClosed = ancestor === project || broad.tempRoots.includes(ancestor);
|
|
1492
|
+
const directory = firstMissing.get(path)!;
|
|
1493
|
+
if (directory || failClosed) {
|
|
1494
|
+
const existed = exists(path);
|
|
1495
|
+
let made: boolean;
|
|
1496
|
+
if (directory) {
|
|
1497
|
+
try { makeDirectory(path); } catch { /* Re-checked below. */ }
|
|
1498
|
+
made = exists(path);
|
|
1499
|
+
} else {
|
|
1500
|
+
made = materialize(path);
|
|
1501
|
+
}
|
|
1502
|
+
if (made) {
|
|
1503
|
+
if (!existed) created.push(`${path}${directory ? "/" : ""}`);
|
|
1504
|
+
readOnly.add(path);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (failClosed) throw new Error(`Cannot protect denied path: ${path}`);
|
|
1404
1508
|
}
|
|
1509
|
+
linkParents.add(ancestor);
|
|
1405
1510
|
return;
|
|
1406
1511
|
}
|
|
1407
1512
|
if (!exists(path)) {
|
|
@@ -1476,6 +1581,9 @@ function buildLinuxBroadCommand(
|
|
|
1476
1581
|
file: bwrap,
|
|
1477
1582
|
fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
1478
1583
|
"--", args.execPath, ...args.execArgs],
|
|
1584
|
+
...(created.length && { notices: [
|
|
1585
|
+
`Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
|
|
1586
|
+
] }),
|
|
1479
1587
|
};
|
|
1480
1588
|
}
|
|
1481
1589
|
|
package/src/tools.ts
CHANGED
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
} from "./output.js";
|
|
15
15
|
import { readOutputControls } from "./shared-log-utils.js";
|
|
16
16
|
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
17
|
-
import { inspectMeta, listMetasForOrigin, writeMeta } from "./registry.js";
|
|
18
|
-
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
17
|
+
import { inspectMeta, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
18
|
+
import { awaitFirstWatchCheck, FIRST_WATCH_CHECK_WAIT_MS, resumeRunningTask, spawnTask, startWatchTask, stopTask, type WatchTaskParams } from "./runtime.js";
|
|
19
19
|
import { runTaskMaintenance } from "./maintenance.js";
|
|
20
20
|
import { ForegroundSandboxBlockedError } from "./sandbox.js";
|
|
21
21
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
@@ -91,11 +91,23 @@ const CommandFields = {
|
|
|
91
91
|
|
|
92
92
|
const SpawnParams = Type.Object(CommandFields);
|
|
93
93
|
|
|
94
|
+
const BlindChecksField = Type.Optional(Type.Integer({ minimum: 0, description: "Flag the watch as possibly blind after this many checks in a row exit 0, write stderr, and match neither success_when nor failure_when: one incident that needs action, with the latest stderr line. The watch keeps running; a check with no stderr or a matched condition recovers it. Default 3; 0 turns it off." }));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How to write a watch check (#359): a check that swallows its own errors reports "pending"
|
|
98
|
+
* forever. Shared by bg_task_watch and the bg_task wrapper.
|
|
99
|
+
*/
|
|
100
|
+
const WATCH_CHECK_GUIDANCE = "Waits up to 15s for the first check and returns its exit code with stdout and stderr tails; if it is still running, says so. "
|
|
101
|
+
+ "Write the check so a broken check is visible: do not end it with `exit 0` or `|| true`, because a check that exits non-zero is recorded and escalates. "
|
|
102
|
+
+ "Map an unknown or unparseable state to failure (exit non-zero), not to pending. Prefer structured output, e.g. `--format=json | jq -er '.status'`, over fragile format strings. "
|
|
103
|
+
+ "A check that exits 0 but writes stderr without matching a condition for blind_checks (default 3) checks in a row is flagged as needing action; if that stderr is expected, redirect it (2>/dev/null) or set blind_checks:0.";
|
|
104
|
+
|
|
94
105
|
const WatchParams = Type.Object({
|
|
95
106
|
...CommandFields,
|
|
96
107
|
interval_seconds: Type.Optional(Type.Number({ description: "Polling interval in seconds. Default 30." })),
|
|
97
108
|
success_when: ConditionSchema,
|
|
98
109
|
failure_when: Type.Optional(ConditionSchema),
|
|
110
|
+
blind_checks: BlindChecksField,
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
const CursorFields = {
|
|
@@ -152,6 +164,7 @@ const ActionParams = Type.Object({
|
|
|
152
164
|
interval_seconds: Type.Optional(Type.Number()),
|
|
153
165
|
success_when: Type.Optional(ConditionSchema),
|
|
154
166
|
failure_when: Type.Optional(ConditionSchema),
|
|
167
|
+
blind_checks: BlindChecksField,
|
|
155
168
|
raw: Type.Optional(Type.Boolean()),
|
|
156
169
|
...HistoryField,
|
|
157
170
|
...CursorFields,
|
|
@@ -215,12 +228,12 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
215
228
|
pi.registerTool({
|
|
216
229
|
name: "bg_task_watch",
|
|
217
230
|
label: "BG Watch",
|
|
218
|
-
description:
|
|
231
|
+
description: `Poll a command in the background until success_when, failure_when, or timeout matches. For remote work, prefer structured ssh: pass ssh:{host,user} and provide the remote command in command; each interval opens a direct one-shot SSH poll without tmux installation. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. Returns its task id once the first check finishes. ${WATCH_CHECK_GUIDANCE} Default timeout 900 seconds; pass timeout_seconds:0 to disable.`,
|
|
219
232
|
promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
|
|
220
233
|
parameters: WatchParams,
|
|
221
|
-
async execute(_toolCallId, params,
|
|
234
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
222
235
|
activeSession = getCallbackOrigin(ctx);
|
|
223
|
-
const launched =
|
|
236
|
+
const launched = await launchWatch(pi, params, ctx.cwd, activeSession, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal);
|
|
224
237
|
refreshBackgroundTasksNavigator(ctx);
|
|
225
238
|
return text(launched);
|
|
226
239
|
},
|
|
@@ -278,15 +291,15 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
278
291
|
pi.registerTool({
|
|
279
292
|
name: "bg_task",
|
|
280
293
|
label: "BG Task",
|
|
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
|
|
294
|
+
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 returns immediately; do not poll in foreground. For action:watch: " + WATCH_CHECK_GUIDANCE + " 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).",
|
|
282
295
|
promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
|
|
283
296
|
parameters: ActionParams,
|
|
284
297
|
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
285
298
|
return renderBackgroundTaskLogDisplay(result, options, theme);
|
|
286
299
|
},
|
|
287
|
-
async execute(_toolCallId, params,
|
|
300
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
288
301
|
activeSession = getCallbackOrigin(ctx);
|
|
289
|
-
return actionText(pi, params, ctx, activeSession, getActiveSession);
|
|
302
|
+
return actionText(pi, params, ctx, activeSession, getActiveSession, signal);
|
|
290
303
|
},
|
|
291
304
|
});
|
|
292
305
|
|
|
@@ -356,11 +369,12 @@ async function actionText(
|
|
|
356
369
|
ctx: ExtensionContext,
|
|
357
370
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
358
371
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
372
|
+
signal?: AbortSignal,
|
|
359
373
|
) {
|
|
360
374
|
if (params.action === "log" && params.id) {
|
|
361
375
|
return logText(String(params.id), logOptions(params, callbackOrigin));
|
|
362
376
|
}
|
|
363
|
-
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession));
|
|
377
|
+
return text(await runAction(pi, params, ctx, callbackOrigin, getActiveSession, signal));
|
|
364
378
|
}
|
|
365
379
|
|
|
366
380
|
async function runAction(
|
|
@@ -369,13 +383,14 @@ async function runAction(
|
|
|
369
383
|
ctx: ExtensionContext,
|
|
370
384
|
callbackOrigin: BackgroundTaskCallbackOrigin,
|
|
371
385
|
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
386
|
+
signal?: AbortSignal,
|
|
372
387
|
): Promise<string> {
|
|
373
388
|
switch (params.action) {
|
|
374
389
|
case "spawn":
|
|
375
390
|
return withNavigatorRefresh(ctx, reportLaunch(() => spawnTask(pi, params, ctx.cwd, callbackOrigin, getActiveSession)));
|
|
376
391
|
case "watch":
|
|
377
392
|
if (!params.success_when) return "Invalid parameters: watch requires success_when.";
|
|
378
|
-
return withNavigatorRefresh(ctx,
|
|
393
|
+
return withNavigatorRefresh(ctx, await launchWatch(pi, params as unknown as WatchTaskParams, ctx.cwd, callbackOrigin, getActiveSession, FIRST_WATCH_CHECK_WAIT_MS, signal));
|
|
379
394
|
case "list":
|
|
380
395
|
return formatList(listOptions(params, callbackOrigin));
|
|
381
396
|
case "status":
|
|
@@ -409,6 +424,33 @@ function reportLaunch(launch: () => BackgroundTaskMeta): string {
|
|
|
409
424
|
}
|
|
410
425
|
}
|
|
411
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Start a watch and wait, bounded by `waitMs`, for its first check, so the launch result
|
|
429
|
+
* shows what the check really prints (#359). Local and SSH watches share this path.
|
|
430
|
+
*/
|
|
431
|
+
export async function launchWatch(
|
|
432
|
+
pi: ExtensionAPI,
|
|
433
|
+
params: WatchTaskParams,
|
|
434
|
+
cwd: string,
|
|
435
|
+
callbackOrigin: BackgroundTaskCallbackOrigin | undefined,
|
|
436
|
+
getActiveSession: () => BackgroundTaskCallbackOrigin | undefined,
|
|
437
|
+
waitMs = FIRST_WATCH_CHECK_WAIT_MS,
|
|
438
|
+
signal?: AbortSignal,
|
|
439
|
+
): Promise<string> {
|
|
440
|
+
let meta: BackgroundTaskMeta;
|
|
441
|
+
try {
|
|
442
|
+
meta = startWatchTask(pi, params, cwd, callbackOrigin, getActiveSession);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error instanceof ForegroundSandboxBlockedError) return error.message;
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
// Esc (the tool's abort signal) ends the wait at once; the watch itself keeps running.
|
|
448
|
+
const outcome = await awaitFirstWatchCheck(meta.id, waitMs, signal);
|
|
449
|
+
const latest = readMeta(meta.id) ?? meta;
|
|
450
|
+
if (outcome && !("pending" in outcome && isTerminalStatus(latest.status))) return formatLaunch(latest, outcome);
|
|
451
|
+
return formatLaunch(latest);
|
|
452
|
+
}
|
|
453
|
+
|
|
412
454
|
function withNavigatorRefresh(ctx: ExtensionContext, result: string): string {
|
|
413
455
|
refreshBackgroundTasksNavigator(ctx);
|
|
414
456
|
return result;
|
package/src/types.ts
CHANGED
|
@@ -42,6 +42,18 @@ export interface CommandResult {
|
|
|
42
42
|
captureTruncated?: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** A watch's first check, as reported in the launch tool result (#359). */
|
|
46
|
+
export interface FirstWatchCheck {
|
|
47
|
+
exitCode: number | null;
|
|
48
|
+
signal: NodeJS.Signals | null;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
timedOut?: boolean;
|
|
51
|
+
stdout: string;
|
|
52
|
+
stderr: string;
|
|
53
|
+
/** Set when the check itself could not run (e.g. an SSH runner error). */
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
export interface RemoteTaskParams {
|
|
46
58
|
session?: "tmux" | "direct";
|
|
47
59
|
install_tmux?: boolean;
|
|
@@ -124,6 +136,14 @@ export interface BackgroundTaskMeta {
|
|
|
124
136
|
spawnPidStartTime?: string;
|
|
125
137
|
successWhen?: Condition;
|
|
126
138
|
failureWhen?: Condition;
|
|
139
|
+
/**
|
|
140
|
+
* Watch only (#359): consecutive exit-0 checks that write stderr and match neither
|
|
141
|
+
* condition before the watch is flagged as possibly blind. 0 turns the rule off;
|
|
142
|
+
* undefined means DEFAULT_BLIND_CHECKS.
|
|
143
|
+
*/
|
|
144
|
+
blindChecks?: number;
|
|
145
|
+
/** Current run of such checks; reset by any other check. */
|
|
146
|
+
blindCheckStreak?: number;
|
|
127
147
|
/**
|
|
128
148
|
* Caller-declared structured intent (#325), validated before launch. A later task of the same
|
|
129
149
|
* kind, cwd, SSH target, and session with the same operationId that succeeds recovers this
|