pi-better-background-tasks 0.4.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.
@@ -109,8 +109,12 @@ export type SandboxCommandArgs = SandboxTarget & {
109
109
  internalHelperExecutable?: boolean;
110
110
  };
111
111
 
112
- /** The wrapper command to spawn: the backend executable and its full argv. */
113
- export type SandboxCommand = { file: string; fileArgs: string[] };
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
- /** Defaults to `fs.realpathSync`. Must throw when the path does not exist. */
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
@@ -414,13 +422,22 @@ function lexicalAndCanonical(home: string, name: string, seams: SandboxSeams): s
414
422
 
415
423
  /**
416
424
  * Every symlink directory entry met while resolving `path`, component by
417
- * component (as `readlink` would follow them). A chain such as
425
+ * component (as `readlink` would follow them), plus where a dangling chain
426
+ * would land. A chain such as
418
427
  * `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
419
428
  * in a removable directory; replacing it would retarget the protected entry, so
420
- * each hop the user could replace is protected too. Bounded: a loop or more
421
- * than 40 hops stops the walk (such a path resolves to nothing).
429
+ * each hop the user could replace is protected too. When a followed link
430
+ * points at something missing, creating it would give the entry content, so
431
+ * the first missing component (which covers every missing directory below it)
432
+ * and the full path it would resolve to are protected as well. Bounded: a loop
433
+ * or more than 40 hops stops the walk (such a path resolves to nothing).
422
434
  */
423
435
  function symlinkHops(path: string): string[] {
436
+ const { hops, unresolved } = walkSymlinks(path);
437
+ return [...hops, ...unresolved];
438
+ }
439
+
440
+ function walkSymlinks(path: string): { hops: string[]; unresolved: string[] } {
424
441
  const hops: string[] = [];
425
442
  const seen = new Set<string>();
426
443
  let followed = 0;
@@ -433,7 +450,13 @@ function symlinkHops(path: string): string[] {
433
450
  if (component === "..") { current = dirname(current); continue; }
434
451
  const entry = join(current, component);
435
452
  let link: boolean;
436
- try { link = lstatSync(entry).isSymbolicLink(); } catch { break; }
453
+ try {
454
+ link = lstatSync(entry).isSymbolicLink();
455
+ } catch {
456
+ // Missing. Only a followed link makes this a dangling target; a
457
+ // plain absent path is guarded as it is.
458
+ return { hops, unresolved: followed ? [...new Set([entry, resolve(entry, ...pending)])] : [] };
459
+ }
437
460
  if (!link) { current = entry; continue; }
438
461
  if (++followed > 40 || seen.has(entry)) break;
439
462
  seen.add(entry);
@@ -448,7 +471,12 @@ function symlinkHops(path: string): string[] {
448
471
  pending = [...target.split(sep), ...pending];
449
472
  }
450
473
  }
451
- return hops;
474
+ return { hops, unresolved: [] };
475
+ }
476
+
477
+ /** Whether a symlink resolves to nothing: dangling (ENOENT), or looping or over 40 hops (ELOOP). */
478
+ function unresolvable(path: string): boolean {
479
+ try { statSync(path); return false; } catch { return true; }
452
480
  }
453
481
 
454
482
  function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
@@ -524,6 +552,67 @@ function contains(root: string, target: string): boolean {
524
552
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
525
553
  }
526
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
+
527
616
  function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
528
617
  return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
529
618
  }
@@ -538,7 +627,7 @@ export function evaluateReadAccess(
538
627
  policy: CompiledSandboxWritePolicy,
539
628
  seams: SandboxSeams = {},
540
629
  ): ReadAccessDecision {
541
- const path = canonicalizePath(target, seams);
630
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
542
631
  const permissions = policy.permissions;
543
632
  if (!permissions) return { allowed: true, path };
544
633
  if (policy.broad) {
@@ -565,7 +654,7 @@ export function evaluateWriteAccess(
565
654
  policy: CompiledSandboxWritePolicy,
566
655
  seams: SandboxSeams = {},
567
656
  ): WriteAccessDecision {
568
- const path = canonicalizePath(target, seams);
657
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
569
658
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
570
659
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
571
660
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -1296,7 +1385,10 @@ function maskSources(profilePath: string): { directory: string; file: string } {
1296
1385
  * home itself stay read-only, so sibling repositories cannot even be edited in
1297
1386
  * place there. Credentials are masked by empty mode-000 mounts; code that runs
1298
1387
  * later and caller-denied paths are read-only binds. Every writable bind and
1299
- * protected leaf is a mount point, which Linux refuses to rename.
1388
+ * protected leaf is a mount point, which Linux refuses to rename. A protected
1389
+ * symlink inside a writable directory (a stow link in `~/.config`) makes that
1390
+ * directory read-only with each existing entry bound writable again, so only
1391
+ * new top-level entries there are refused.
1300
1392
  */
1301
1393
  function buildLinuxBroadCommand(
1302
1394
  bwrap: string,
@@ -1347,15 +1439,74 @@ function buildLinuxBroadCommand(
1347
1439
  const readOnly = new Set<string>();
1348
1440
  const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
1349
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);
1445
+ // Directories holding a protected symlink: read-only, so the link cannot be
1446
+ // replaced, with their existing entries bound writable again below.
1447
+ const linkParents = new Set<string>();
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 }));
1350
1464
  const guard = (path: string, hide: boolean) => {
1351
1465
  let directory: boolean | undefined;
1352
- if (canonicalizePath(path, seams) !== path) {
1466
+ let link = false;
1467
+ try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
1468
+ if (canonicalizePath(path, seams) !== path || (link && unresolvable(path))) {
1353
1469
  // A literal twin of a canonical deny entry: its target is guarded on
1354
- // its own. A symlink leaf inside a writable root could be replaced,
1355
- // so its directory becomes read-only; home itself already is.
1356
- let link = false;
1357
- try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
1358
- if (link && dirname(path) !== broad.home && writableAt(dirname(path))) readOnly.add(dirname(path));
1470
+ // its own. A looping link resolves to nothing (ELOOP), and a dangling
1471
+ // one's missing target is guarded below, so only the link itself
1472
+ // needs guarding here. A symlink leaf inside a writable root could be
1473
+ // replaced, so its directory is protected; home already is.
1474
+ if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
1475
+ return;
1476
+ }
1477
+ if (belowFirstMissing.has(path) && !firstMissing.has(path)) return; // Its first missing component is guarded.
1478
+ if (firstMissing.has(path)) {
1479
+ // Where a dangling link would land: nothing may be created there.
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.
1488
+ if (!writableAt(path)) return;
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}`);
1508
+ }
1509
+ linkParents.add(ancestor);
1359
1510
  return;
1360
1511
  }
1361
1512
  if (!exists(path)) {
@@ -1384,19 +1535,37 @@ function buildLinuxBroadCommand(
1384
1535
  for (const path of writeDenied) guard(path, false);
1385
1536
  if (permissions.projectFiles === "read") readOnly.add(project);
1386
1537
 
1538
+ // A read-only link parent keeps its existing entries writable: each one is
1539
+ // bound back read-write (a mount point, so it cannot be renamed away either).
1540
+ // Only new top-level entries in that directory are lost. Protected entries
1541
+ // keep their own mounts, and a symlink entry is never bound: a bind follows
1542
+ // it, and writes through it already meet its target's own mounts.
1543
+ const rebound = new Set<string>();
1544
+ for (const parent of linkParents) readOnly.add(parent);
1545
+ for (const parent of linkParents) {
1546
+ for (const entry of list(parent)) {
1547
+ if (entry.symlink) continue;
1548
+ const path = join(parent, entry.name);
1549
+ if (insideProtected(path) || readOnly.has(path) || writable.has(path)) continue;
1550
+ rebound.add(path);
1551
+ }
1552
+ }
1553
+
1387
1554
  // Intermediate directories between a writable root and anything bound
1388
1555
  // inside it become mount points too, so none of them can be renamed away.
1389
1556
  const anchors = new Set<string>();
1390
1557
  for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
1391
1558
  for (const parent of protectedAncestors([target])) {
1392
1559
  if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
1393
- !readOnly.has(parent) && !writable.has(parent)) anchors.add(parent);
1560
+ !readOnly.has(parent) && !writable.has(parent) && !rebound.has(parent)) anchors.add(parent);
1394
1561
  }
1395
1562
  }
1396
- type Mount = { option: "--bind" | "--ro-bind"; source: string; path: string; rank: number };
1563
+ type Mount = { option: "--bind" | "--bind-try" | "--ro-bind"; source: string; path: string; rank: number };
1397
1564
  const mounts: Mount[] = [
1398
1565
  ...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
1399
1566
  ...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
1567
+ // `-try`: an entry removed before a resumed launch stays read-only instead of failing it.
1568
+ ...[...rebound].map((path) => ({ option: "--bind-try" as const, source: path, path, rank: 1 })),
1400
1569
  ...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
1401
1570
  ...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
1402
1571
  ].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
@@ -1412,6 +1581,9 @@ function buildLinuxBroadCommand(
1412
1581
  file: bwrap,
1413
1582
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1414
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
+ ] }),
1415
1587
  };
1416
1588
  }
1417
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";
@@ -71,7 +71,7 @@ const RemoteSchema = Type.Object({
71
71
  // TypeBox union, and a Null-first union reports a malformed array as "must be null".
72
72
  const IntentFields = {
73
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." })),
74
+ expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). 0 is allowed and ignored: exit 0 is already success. Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
75
75
  };
76
76
 
77
77
  const CommandFields = {
@@ -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: "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 immediately with its task id. Default timeout 900 seconds; pass timeout_seconds:0 to disable.",
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, _signal, _onUpdate, ctx) {
234
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
222
235
  activeSession = getCallbackOrigin(ctx);
223
- const launched = reportLaunch(() => startWatchTask(pi, params, ctx.cwd, activeSession, getActiveSession));
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/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).",
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, _signal, _onUpdate, ctx) {
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, reportLaunch(() => startWatchTask(pi, params as never, ctx.cwd, callbackOrigin, getActiveSession)));
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