@skydiveai/pi-extensions 0.1.0-beta.150 → 0.1.0-beta.1502

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.
Files changed (2) hide show
  1. package/dist/index.mjs +605 -90
  2. package/package.json +2 -9
package/dist/index.mjs CHANGED
@@ -19,6 +19,9 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
19
19
  import { Resource } from "@opentelemetry/resources";
20
20
  import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
21
21
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
22
+ import { execFile } from "node:child_process";
23
+ import { availableParallelism } from "node:os";
24
+ import { promisify } from "node:util";
22
25
  import { hc } from "hono/client";
23
26
  import { parse } from "yaml";
24
27
  import { quote } from "shell-quote";
@@ -256,7 +259,7 @@ function createHealthHandler({ metadata }) {
256
259
  * read on the hot path before every LLM call), it falls back to the default
257
260
  * for that knob and logs once.
258
261
  */
259
- const log$13 = logger.child({ module: "context-management-config" });
262
+ const log$15 = logger.child({ module: "context-management-config" });
260
263
  const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
261
264
  enabled: false,
262
265
  perResultMaxBytes: 16 * 1024,
@@ -304,7 +307,7 @@ function resolveContextManagementConfig(env = process.env) {
304
307
  maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
305
308
  });
306
309
  if (!parsed.success) {
307
- log$13.warn({
310
+ log$15.warn({
308
311
  event: "context_management_config_invalid",
309
312
  err: parsed.error
310
313
  }, "falling back to default context-management config");
@@ -438,7 +441,7 @@ const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task i
438
441
  * or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
439
442
  * `tools/_example.ts` documents the shape without registering.
440
443
  */
441
- const log$12 = logger.child({ module: "local-tools-extension" });
444
+ const log$14 = logger.child({ module: "local-tools-extension" });
442
445
  const TOOLS_DIRNAME = "tools";
443
446
  const fileState = /* @__PURE__ */ new Map();
444
447
  let pendingLocalToolsUpdate = null;
@@ -582,7 +585,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
582
585
  dir
583
586
  });
584
587
  if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
585
- log$12.info({
588
+ log$14.info({
586
589
  event: "local_tools_reconcile",
587
590
  reason,
588
591
  total_tools: summary.totalTools,
@@ -604,7 +607,7 @@ const localToolsExtension = (pi) => {
604
607
  reason: "session_start"
605
608
  });
606
609
  } catch (err) {
607
- log$12.error({
610
+ log$14.error({
608
611
  err,
609
612
  event: "local_tools_reconcile_failed"
610
613
  }, "local tools reconcile failed");
@@ -616,7 +619,7 @@ const localToolsExtension = (pi) => {
616
619
  try {
617
620
  current = await listToolFiles(dir);
618
621
  } catch (err) {
619
- log$12.warn({
622
+ log$14.warn({
620
623
  err,
621
624
  event: "local_tools_listing_failed"
622
625
  }, "tools/ listing failed");
@@ -638,7 +641,7 @@ const localToolsExtension = (pi) => {
638
641
  reason: "auto_reload"
639
642
  });
640
643
  } catch (err) {
641
- log$12.error({
644
+ log$14.error({
642
645
  err,
643
646
  event: "local_tools_auto_reload_failed"
644
647
  }, "auto-reload after tools/ change failed");
@@ -692,6 +695,19 @@ const STDERR_BUFFER_BYTES = 4096;
692
695
  * indistinguishable from any other transport problem. Walk the cause
693
696
  * chain so the agent sees the real underlying error.
694
697
  */
698
+ /**
699
+ * True when an error from an http MCP transport (connect, listTools, or a tool
700
+ * call) is an authentication failure. With no authProvider configured the SDK
701
+ * surfaces a 401 as `StreamableHTTPError(401)`; older paths translate it to
702
+ * `UnauthorizedError`. A dead/expired OAuth token (the proxy can no longer
703
+ * mint one) shows up here on the NEXT request against a previously-connected
704
+ * client — not just at connect — so reconcile must re-classify such a failure
705
+ * as `pending_auth` instead of a generic `failed`, keeping the "waiting on
706
+ * auth" report consistent with `platform auth`.
707
+ */
708
+ function isUnauthorizedError(err) {
709
+ return err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401;
710
+ }
695
711
  function formatError(err) {
696
712
  if (!(err instanceof Error)) return String(err);
697
713
  const parts = [err.message];
@@ -713,7 +729,7 @@ async function connectHttp(_id, config, client) {
713
729
  stderr: null
714
730
  };
715
731
  } catch (err) {
716
- if (err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401) return {
732
+ if (isUnauthorizedError(err)) return {
717
733
  status: "pending_auth",
718
734
  client,
719
735
  stderr: "",
@@ -863,7 +879,7 @@ async function loadMcpConfig(path) {
863
879
  * Clients are keyed by JSON-stringified config and reused across
864
880
  * reloads — only changed configs reconnect.
865
881
  */
866
- const log$11 = logger.child({ module: "mcp-extension" });
882
+ const log$13 = logger.child({ module: "mcp-extension" });
867
883
  async function closeConnected(connected) {
868
884
  try {
869
885
  await connected.client.close();
@@ -1190,6 +1206,64 @@ var McpExtension = class {
1190
1206
  try {
1191
1207
  mcpTools = (await connected.client.listTools()).tools;
1192
1208
  } catch (err) {
1209
+ if (isUnauthorizedError(err) && serverConfig.transport === "http") {
1210
+ await closeConnected(connected);
1211
+ const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
1212
+ if (retry.status === "pending_auth") return {
1213
+ id,
1214
+ store: {
1215
+ client: retry.client,
1216
+ configKey,
1217
+ status: "pending_auth",
1218
+ stderrBuffer: null,
1219
+ cliHint: retry.cliHint
1220
+ },
1221
+ serverStatus: {
1222
+ status: "pending_auth",
1223
+ stderr: "",
1224
+ cliHint: retry.cliHint
1225
+ },
1226
+ change: null,
1227
+ error: null,
1228
+ tools: null
1229
+ };
1230
+ if (retry.status === "failed") return {
1231
+ id,
1232
+ store: null,
1233
+ serverStatus: {
1234
+ status: "failed",
1235
+ error: retry.error,
1236
+ stderr: retry.stderr
1237
+ },
1238
+ change: null,
1239
+ error: {
1240
+ serverId: id,
1241
+ message: retry.error
1242
+ },
1243
+ tools: null
1244
+ };
1245
+ if (retry.status === "connected") {
1246
+ connected = {
1247
+ client: retry.client,
1248
+ configKey,
1249
+ status: "connected",
1250
+ stderrBuffer: retry.stderr,
1251
+ cliHint: null
1252
+ };
1253
+ mcpTools = (await connected.client.listTools()).tools;
1254
+ return {
1255
+ id,
1256
+ store: connected,
1257
+ serverStatus: { status: "connected" },
1258
+ change: action === "reused" ? "refreshed" : action,
1259
+ error: null,
1260
+ tools: {
1261
+ client: connected.client,
1262
+ list: mcpTools
1263
+ }
1264
+ };
1265
+ }
1266
+ }
1193
1267
  const message = err instanceof Error ? err.message : String(err);
1194
1268
  const stderr = connected.stderrBuffer?.read() ?? "";
1195
1269
  return {
@@ -1227,7 +1301,7 @@ var McpExtension = class {
1227
1301
  });
1228
1302
  this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1229
1303
  if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
1230
- log$11.info({
1304
+ log$13.info({
1231
1305
  event: "mcp_reconcile",
1232
1306
  reason,
1233
1307
  total_tools: summary.totalTools,
@@ -1250,7 +1324,7 @@ var McpExtension = class {
1250
1324
  reason: "session_start"
1251
1325
  });
1252
1326
  } catch (err) {
1253
- log$11.error({
1327
+ log$13.error({
1254
1328
  err,
1255
1329
  event: "mcp_reconcile_failed"
1256
1330
  }, "MCP reconcile failed");
@@ -1262,7 +1336,7 @@ var McpExtension = class {
1262
1336
  try {
1263
1337
  mtime = await readConfigMtimeMs(configPath);
1264
1338
  } catch (err) {
1265
- log$11.warn({
1339
+ log$13.warn({
1266
1340
  err,
1267
1341
  event: "mcp_mtime_check_failed"
1268
1342
  }, "mtime check on mcp.config.json failed");
@@ -1276,7 +1350,7 @@ var McpExtension = class {
1276
1350
  reason: "auto_reload"
1277
1351
  });
1278
1352
  } catch (err) {
1279
- log$11.error({
1353
+ log$13.error({
1280
1354
  err,
1281
1355
  event: "mcp_auto_reload_failed"
1282
1356
  }, "auto-reload after mcp.config.json change failed");
@@ -1639,16 +1713,380 @@ const bashDefaultTimeoutExtension = (pi) => {
1639
1713
  });
1640
1714
  };
1641
1715
  //#endregion
1716
+ //#region src/extensions/resource-pressure-warning.ts
1717
+ /**
1718
+ * Mid-run resource-pressure warning to the agent.
1719
+ *
1720
+ * The sandbox already detects pressure — the boot scripts cap the
1721
+ * user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
1722
+ * edges for memory and disk — but nothing told the *agent*, so a turn burned
1723
+ * straight to the OOM kill (or a full disk) and only learned about it from
1724
+ * the post-mortem notice. This extension closes that gap in-process: while a
1725
+ * turn is active it polls the agent cgroup and the root filesystem and, the
1726
+ * first time usage crosses a warn threshold, folds a system notification into
1727
+ * the open turn so the agent can checkpoint, shed work (constrain
1728
+ * parallelism, kill a background hog, clean scratch space), or request a
1729
+ * bigger tier BEFORE the kill.
1730
+ *
1731
+ * The notification is triggered by the two conditions that actually kill
1732
+ * work — memory near the cgroup hard cap, disk near full — and reports a
1733
+ * snapshot of all the relevant stats (memory, CPU utilization, disk) so the
1734
+ * agent can tell which resource is the problem and how much headroom the
1735
+ * others have.
1736
+ *
1737
+ * Edge-triggered, once per trigger per turn: the fired flags reset on
1738
+ * agent_start, so a turn that rides a threshold gets one warning per
1739
+ * resource, not a stream. Polling only runs while the agent is active — an
1740
+ * idle sandbox's resource usage is not the agent's problem and there is no
1741
+ * open turn to deliver into anyway.
1742
+ *
1743
+ * Best-effort throughout: any read failure (cgroup absent, controller not
1744
+ * delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
1745
+ * stat and the extension warns on what it can see — it must never break a
1746
+ * turn over an observability feature.
1747
+ */
1748
+ const execFileAsync = promisify(execFile);
1749
+ const log$12 = logger.child({ module: "resource-pressure-warning" });
1750
+ const POLL_INTERVAL_MS = 1e4;
1751
+ function envOverride(name) {
1752
+ for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
1753
+ const value = process.env[`${prefix}${name}`];
1754
+ if (value != null && value !== "") return value;
1755
+ }
1756
+ return null;
1757
+ }
1758
+ function cgroupDir() {
1759
+ return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
1760
+ }
1761
+ function diskRoot() {
1762
+ return envOverride("DISK_ROOT") ?? "/";
1763
+ }
1764
+ /**
1765
+ * Read a cgroup v2 scalar file. Returns a number, or null for "max"
1766
+ * (uncapped), an empty/absent file, or any read/parse error — an uncapped or
1767
+ * unreadable limit means there is nothing meaningful to warn against.
1768
+ */
1769
+ async function readScalar(file) {
1770
+ try {
1771
+ const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
1772
+ if (raw === "" || raw === "max") return null;
1773
+ const n = Number(raw);
1774
+ return Number.isFinite(n) ? n : null;
1775
+ } catch (_error) {
1776
+ return null;
1777
+ }
1778
+ }
1779
+ /**
1780
+ * Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
1781
+ * cpu.stat) and return the counter for `key`, or null when absent.
1782
+ */
1783
+ async function readKeyedCounter(file, key) {
1784
+ try {
1785
+ const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
1786
+ for (const line of raw.split("\n")) {
1787
+ const [k, v] = line.trim().split(/\s+/);
1788
+ if (k === key) {
1789
+ const n = Number(v);
1790
+ return Number.isFinite(n) ? n : null;
1791
+ }
1792
+ }
1793
+ return null;
1794
+ } catch (_error) {
1795
+ return null;
1796
+ }
1797
+ }
1798
+ /**
1799
+ * Live memory usage as an integer percent of the hard cap, or null when
1800
+ * either side is unreadable/uncapped. Exported for tests.
1801
+ */
1802
+ async function readMemUsePct() {
1803
+ const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
1804
+ if (current === null || max === null || max <= 0) return null;
1805
+ return {
1806
+ pct: Math.floor(current / max * 100),
1807
+ currentBytes: current,
1808
+ maxBytes: max
1809
+ };
1810
+ }
1811
+ /**
1812
+ * Root filesystem used% (df -P Capacity column), or null on any failure.
1813
+ * Exported for tests.
1814
+ */
1815
+ async function readDiskUsePct() {
1816
+ try {
1817
+ const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
1818
+ const dataRow = stdout.trim().split("\n")[1];
1819
+ if (dataRow == null) return null;
1820
+ const capacity = dataRow.trim().split(/\s+/)[4];
1821
+ if (capacity == null) return null;
1822
+ const pct = Number(capacity.replace("%", ""));
1823
+ return Number.isFinite(pct) ? pct : null;
1824
+ } catch (_error) {
1825
+ return null;
1826
+ }
1827
+ }
1828
+ /**
1829
+ * CPU utilization sampler. cgroup v2 exposes cumulative CPU time
1830
+ * (cpu.stat usage_usec); utilization is the delta between two samples over
1831
+ * the wall time between them, normalized by core count. The first call after
1832
+ * construction has no previous sample and returns null.
1833
+ */
1834
+ function createCpuSampler() {
1835
+ let prevUsageUsec = null;
1836
+ let prevAtMs = null;
1837
+ return async () => {
1838
+ const usage = await readKeyedCounter("cpu.stat", "usage_usec");
1839
+ const now = Date.now();
1840
+ const prev = prevUsageUsec;
1841
+ const prevAt = prevAtMs;
1842
+ prevUsageUsec = usage;
1843
+ prevAtMs = now;
1844
+ if (usage === null || prev === null || prevAt === null) return null;
1845
+ const wallUsec = (now - prevAt) * 1e3;
1846
+ if (wallUsec <= 0) return null;
1847
+ const cores = availableParallelism();
1848
+ const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
1849
+ return Math.max(0, Math.min(100, pct));
1850
+ };
1851
+ }
1852
+ function fmtMb(bytes) {
1853
+ return Math.round(bytes / 1024 / 1024);
1854
+ }
1855
+ /** The model-facing warning text. Exported for tests. */
1856
+ function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
1857
+ const stats = [];
1858
+ if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
1859
+ if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
1860
+ if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
1861
+ const lead = trigger === "memory" ? `Your sandbox is at ${mem?.pct}% of its memory cap. If usage keeps climbing, the kernel will kill the offending process and this turn may die with it.` : `Your sandbox's disk is ${diskPct}% full. If it fills completely, writes will start failing and this turn may die with them.`;
1862
+ const remedy = trigger === "memory" ? "checkpoint in-flight work (commit and push), then reduce the footprint — constrain parallelism, run heavy steps sequentially, or kill background processes you no longer need." : "checkpoint in-flight work (commit and push), then free space — clean build artifacts, caches, and scratch files you no longer need.";
1863
+ return `<system_notification>${lead} Current usage: ${stats.join(", ")}. Act now: ${remedy} If the workload genuinely needs more resources, request a bigger sandbox with \`platform compute request\`. This is an automated resource warning, not a message from the user; continue the task, adjusted.</system_notification>`;
1864
+ }
1865
+ const resourcePressureWarningExtension = (pi) => {
1866
+ let agentActive = false;
1867
+ let warnedMemThisTurn = false;
1868
+ let warnedDiskThisTurn = false;
1869
+ let timer = null;
1870
+ const sampleCpu = createCpuSampler();
1871
+ async function checkOnce() {
1872
+ if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
1873
+ const [mem, cpuPct, diskPct] = await Promise.all([
1874
+ readMemUsePct(),
1875
+ sampleCpu(),
1876
+ readDiskUsePct()
1877
+ ]);
1878
+ let trigger = null;
1879
+ if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
1880
+ trigger = "memory";
1881
+ warnedMemThisTurn = true;
1882
+ } else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
1883
+ trigger = "disk";
1884
+ warnedDiskThisTurn = true;
1885
+ }
1886
+ if (trigger === null) return;
1887
+ log$12.warn({
1888
+ trigger,
1889
+ mem,
1890
+ cpuPct,
1891
+ diskPct
1892
+ }, "resource pressure warning delivered to agent");
1893
+ await pi.sendMessage({
1894
+ customType: "anyone-resource-pressure-warning",
1895
+ content: resourcePressureWarningText(trigger, {
1896
+ mem,
1897
+ cpuPct,
1898
+ diskPct
1899
+ }),
1900
+ display: false
1901
+ }, {
1902
+ triggerTurn: true,
1903
+ deliverAs: "followUp"
1904
+ });
1905
+ }
1906
+ pi.on("agent_start", async () => {
1907
+ agentActive = true;
1908
+ warnedMemThisTurn = false;
1909
+ warnedDiskThisTurn = false;
1910
+ if (!timer) {
1911
+ timer = setInterval(() => {
1912
+ checkOnce().catch((err) => {
1913
+ log$12.error({ err }, "resource pressure check failed");
1914
+ });
1915
+ }, POLL_INTERVAL_MS);
1916
+ timer.unref?.();
1917
+ }
1918
+ });
1919
+ pi.on("agent_end", async () => {
1920
+ agentActive = false;
1921
+ if (timer) {
1922
+ clearInterval(timer);
1923
+ timer = null;
1924
+ }
1925
+ });
1926
+ };
1927
+ //#endregion
1928
+ //#region src/extensions/disk-guard.ts
1929
+ const log$11 = logger.child({ module: "disk-guard" });
1930
+ /**
1931
+ * In-band bypass. The guard is a safety net, not a jail: when the agent knows
1932
+ * a flagged command is genuinely safe (writing to a different mount, a tiny
1933
+ * bounded download, a delete-then-clone one-liner, an emergency it accepts the
1934
+ * risk on) it can force the command through by appending this marker as a
1935
+ * trailing shell comment. Kept as a comment so it never changes what the
1936
+ * command does, and matched case-insensitively with flexible spacing so the
1937
+ * agent doesn't have to reproduce it byte-for-byte.
1938
+ */
1939
+ const BYPASS_MARKER = /#\s*disk-guard:\s*allow\b/i;
1940
+ /** The exact marker text the block message tells the agent to append. */
1941
+ const BYPASS_HINT = "# disk-guard: allow";
1942
+ /**
1943
+ * Harness-level kill switch: set DISK_GUARD_DISABLE=1 to turn the guard off
1944
+ * entirely. This is the "I own my harness, let me opt out" knob — an agent
1945
+ * that boots its own harness can disable the guard for its whole process
1946
+ * without a code roll, and it's also the fleet-wide escape hatch if the
1947
+ * classifier ever misfires and blocks real work. The bare name is honored
1948
+ * first; the SKYDIVE_/ANYONE_ prefixes are accepted too for consistency with
1949
+ * the other env overrides. Empty/unset/"0"/"false" leave the guard on.
1950
+ */
1951
+ function guardDisabledByEnv() {
1952
+ for (const name of [
1953
+ "DISK_GUARD_DISABLE",
1954
+ "SKYDIVE_DISK_GUARD_DISABLE",
1955
+ "ANYONE_DISK_GUARD_DISABLE"
1956
+ ]) {
1957
+ const value = process.env[name];
1958
+ if (value != null && value !== "" && value !== "0" && value !== "false") return true;
1959
+ }
1960
+ return false;
1961
+ }
1962
+ /** True when the command carries the in-band bypass marker. */
1963
+ function hasBypassMarker(command) {
1964
+ return BYPASS_MARKER.test(command);
1965
+ }
1966
+ /**
1967
+ * Commands that reclaim space or merely inspect it. If any of these verbs
1968
+ * appears in the command line, we never block — otherwise the guard would trap
1969
+ * the agent by blocking the exact command it needs to dig out. Matched as
1970
+ * whole words so `remove-item` etc. don't accidentally match `rm`.
1971
+ */
1972
+ const RECLAIM_PATTERNS = [
1973
+ /\brm\b/,
1974
+ /\brmdir\b/,
1975
+ /\bdf\b/,
1976
+ /\bdu\b/,
1977
+ /\bncdu\b/,
1978
+ /\bfind\b[^|]*\s-delete\b/,
1979
+ /\btruncate\b/,
1980
+ /\bgit\s+(gc|prune|clean|worktree\s+remove|worktree\s+prune)\b/,
1981
+ /\b(yarn|npm|pnpm|bun)\s+.*\b(cache\s+clean|cache\s+clear|store\s+prune)\b/,
1982
+ /\bcache\s+(clean|clear|prune)\b/,
1983
+ /\b(docker|podman)\s+.*\bprune\b/,
1984
+ /\bapt(-get)?\s+clean\b/,
1985
+ /\bjournalctl\b[^|]*--vacuum/
1986
+ ];
1987
+ /**
1988
+ * File extensions that mean a download is actually LARGE — archives, disk
1989
+ * images, compiled/binary artifacts, model weights, media. A curl/wget is only
1990
+ * gated when it writes one of these; an API/page fetch to a `.json`/`.html`/
1991
+ * `.txt` file is tiny and must not be blocked. Derived from 4,144 real
1992
+ * commands: ~64% of `curl -o` uses were tiny fetches, only ~4% large.
1993
+ */
1994
+ const BIG_DOWNLOAD_EXT = "(?:tar\\.gz|tgz|tar|zip|iso|gz|bz2|xz|zst|deb|rpm|pkg|dmg|whl|jar|7z|img|mp4|mov|avi|mkv|onnx|gguf|safetensors|bin|node)";
1995
+ /**
1996
+ * Commands that consume a meaningful amount of disk. Kept deliberately tight
1997
+ * and high-precision: validated against 4,144 real commands from the last 7
1998
+ * days, the earlier "writes a file" heuristic flagged 82% of everything (a
1999
+ * `curl -o /tmp/x.json` API call is not a disk event). This set flags ~33%,
2000
+ * almost all genuinely large — real installs, clones, big-archive downloads,
2001
+ * extractions. What was DROPPED and why:
2002
+ * - `git fetch` / `git pull` — incremental on an existing clone, usually tiny.
2003
+ * - `git checkout` — overwhelmingly `git checkout <ref> -- <file>` or a
2004
+ * branch switch, ~zero net growth; the rare full materialization isn't
2005
+ * worth the false-positive rate.
2006
+ * - bare `curl -o` / `wget -o` — see BIG_DOWNLOAD_EXT above.
2007
+ * - loose `… build` — matched `--mode=skip-build`, `oxfmt … build`, prose.
2008
+ * The remaining big-disk op in escher is `git clone` and `git worktree add`
2009
+ * (which is really a checkout), both kept.
2010
+ */
2011
+ const SPACE_HUNGRY_PATTERNS = [
2012
+ /\bgit\s+clone\b/,
2013
+ /\bgit\s+worktree\s+add\b/,
2014
+ /\b(yarn|npm|pnpm|bun)\s+(install|add|ci)\b/,
2015
+ /\byarn\s*$/,
2016
+ /\byarn\s+--(?!version|help)\S/,
2017
+ /\bpip3?\s+install\b/,
2018
+ /\bapt(-get)?\s+install\b/,
2019
+ /\bnpm\s+pack\b/,
2020
+ /\bdocker\s+(build|pull)\b/,
2021
+ new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b`, "i"),
2022
+ new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b`, "i"),
2023
+ /\btar\s+[^\n|]*x[^\n|]*f/,
2024
+ /\bunzip\b/,
2025
+ /\bdd\b[^\n|]*\bof=/
2026
+ ];
2027
+ /**
2028
+ * True when the command reclaims or inspects space — these are always allowed,
2029
+ * even on a 100%-full box, so the agent can dig itself out.
2030
+ */
2031
+ function isReclaimCommand(command) {
2032
+ return RECLAIM_PATTERNS.some((re) => re.test(command));
2033
+ }
2034
+ /**
2035
+ * True when the command is likely to consume a meaningful amount of disk.
2036
+ * A reclaim/inspect command is never space-hungry — the reclaim check wins so a
2037
+ * `git worktree remove` or a `yarn cache clean` is never mistaken for growth.
2038
+ */
2039
+ function isSpaceHungryCommand(command) {
2040
+ if (isReclaimCommand(command)) return false;
2041
+ return SPACE_HUNGRY_PATTERNS.some((re) => re.test(command));
2042
+ }
2043
+ /**
2044
+ * The decision, factored out and pure so it's exhaustively testable without a
2045
+ * real filesystem. Block only when we have a disk reading, it's at/above the
2046
+ * critical threshold, the command is space-hungry (and not a reclaim), and the
2047
+ * agent hasn't explicitly opted out with the bypass marker.
2048
+ */
2049
+ function shouldBlockForDisk(command, diskPct) {
2050
+ if (diskPct === null) return false;
2051
+ if (diskPct < 95) return false;
2052
+ if (hasBypassMarker(command)) return false;
2053
+ return isSpaceHungryCommand(command);
2054
+ }
2055
+ /** The agent-facing explanation returned as the blocked tool result. */
2056
+ function diskBlockReason(command, diskPct) {
2057
+ return `Blocked: the sandbox disk is ${diskPct}% full and this command (\`${command.trim().slice(0, 120)}\`) writes a large amount, so it would fail partway with ENOSPC and leave a corrupt result. Reclaim space FIRST, then retry. Free ONLY what THIS conversation created — scratch/build output you wrote this run, downloads you're done with, and worktrees/branches whose work you've already committed and pushed (\`git worktree remove\`, \`yarn cache clean\`, delete your own scratch). Do NOT blindly wipe /tmp or delete a clone/worktree you don't recognize — other conversations share this box. Check headroom with \`df -h /\` and \`du -sh ~/workspace/* 2>/dev/null\`. If you genuinely can't free enough, stop and tell the user you're blocked on disk rather than retrying the write. If you're certain this command is safe anyway (writes elsewhere, tiny bounded size, delete-then-write), force it through by appending \` ${BYPASS_HINT}\` to the command.`;
2058
+ }
2059
+ const diskGuardExtension = (pi) => {
2060
+ pi.on("tool_call", async (event) => {
2061
+ if (event.toolName !== "bash") return;
2062
+ if (guardDisabledByEnv()) return;
2063
+ const command = event.input.command;
2064
+ if (typeof command !== "string" || command.length === 0) return;
2065
+ if (hasBypassMarker(command)) return;
2066
+ if (!isSpaceHungryCommand(command)) return;
2067
+ const diskPct = await readDiskUsePct();
2068
+ if (!shouldBlockForDisk(command, diskPct)) return;
2069
+ log$11.warn({
2070
+ diskPct,
2071
+ command: command.slice(0, 200)
2072
+ }, "blocked space-hungry bash command on near-full disk");
2073
+ return {
2074
+ block: true,
2075
+ reason: diskBlockReason(command, diskPct)
2076
+ };
2077
+ });
2078
+ };
2079
+ //#endregion
1642
2080
  //#region src/channel-context-ref.ts
1643
2081
  /**
1644
- * The worker injects only a reference — `{ channel, messageId }` — into the
1645
- * sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
2082
+ * The worker injects a small reference — `{ channel, messageId, runId }` —
2083
+ * into the sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
1646
2084
  *
1647
2085
  * The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
1648
2086
  * `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
1649
2087
  * `@createinc/*` dependencies — importing that package would pull the whole
1650
- * platform channel stack (Slack/email/Linq SDKs, messaging) in just to read
1651
- * two fields. So we validate the (stable) shape locally instead.
2088
+ * platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
2089
+ * field. So we validate the field this consumer needs locally instead.
1652
2090
  */
1653
2091
  const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
1654
2092
  /**
@@ -1680,6 +2118,23 @@ function apiBaseUrl() {
1680
2118
  }
1681
2119
  //#endregion
1682
2120
  //#region src/extensions/platform.ts
2121
+ /**
2122
+ * Platform extension — bridges the agent harness to the Skydive platform daemon.
2123
+ *
2124
+ * Responsibilities:
2125
+ * - Heartbeat: periodic POST to the API so the sandbox manager knows the
2126
+ * agent is alive. Throttled to once per minute, triggered by tool events.
2127
+ * - Session tracking: registers the session with the daemon on start,
2128
+ * streams tool_call / tool_result events so the daemon can track which
2129
+ * session is actively executing, and signals session end on agent_end.
2130
+ * - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
2131
+ * messageId) to the daemon so file writes can be attributed to the
2132
+ * correct conversation.
2133
+ *
2134
+ * All daemon POSTs are fire-and-forget — failures are logged but never
2135
+ * block the agent. The daemon may not be running (e.g. local dev without
2136
+ * a sandbox), and that's fine.
2137
+ */
1683
2138
  const HEARTBEAT_THROTTLE_MS = 6e4;
1684
2139
  const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
1685
2140
  const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
@@ -1691,9 +2146,56 @@ function sandboxClient() {
1691
2146
  return hc(`${apiUrl}/api/v1/sandbox`);
1692
2147
  }
1693
2148
  /**
1694
- * Fetch every harness feature flag in one GET (`{ contextManagement, subagent,
1695
- * ... }` see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1696
- * null when indeterminate (no api url, or the request failed) so the shared
2149
+ * Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
2150
+ * feature-flags half of the ANY-5184 pool 403 wave.)
2151
+ *
2152
+ * `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
2153
+ * from a pool box can only 403 — a guaranteed-failing GET every 60s for the
2154
+ * life of the pool phase. The discriminator is the sandbox token's `type`
2155
+ * claim, read UNVERIFIED (this box never holds the signing secret): not an
2156
+ * authorization decision, only "should I bother calling?", and the api still
2157
+ * authorizes every request.
2158
+ *
2159
+ * Read per call from the daemon's persisted env file, NOT process.env:
2160
+ * claiming a pool box rebinds the token in place (the daemon rewrites this
2161
+ * file) while the harness's process.env keeps the boot snapshot, so a
2162
+ * process-env gate would leave a claimed box permanently skipping — trading a
2163
+ * wasted request for silently frozen flags, which is strictly worse. "Cannot
2164
+ * tell" (no file, no token, unparseable payload) reports false so the poll
2165
+ * proceeds.
2166
+ */
2167
+ const daemonEnvIdentitySchema = z.object({
2168
+ ANYONE_SANDBOX_TOKEN: z.string().optional(),
2169
+ SKYDIVE_SANDBOX_TOKEN: z.string().optional()
2170
+ }).passthrough();
2171
+ const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
2172
+ async function isPoolIdentity() {
2173
+ try {
2174
+ const override = process.env.ANYONE_DAEMON_ENV_CACHE;
2175
+ const candidates = override ? [override] : ["/run/anyone-system/daemon-env.json", "/tmp/.anyone/daemon-env.json"];
2176
+ let raw = null;
2177
+ for (const file of candidates) {
2178
+ raw = await readFile(file, "utf8").catch(() => null);
2179
+ if (raw !== null) break;
2180
+ }
2181
+ if (raw === null) return false;
2182
+ const env = daemonEnvIdentitySchema.safeParse(JSON.parse(raw));
2183
+ if (!env.success) return false;
2184
+ const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
2185
+ if (typeof token !== "string" || token === "") return false;
2186
+ const payload = token.split(".")[1];
2187
+ if (!payload) return false;
2188
+ const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
2189
+ return claims.success && claims.data.type === "onboarding-pool";
2190
+ } catch (_err) {
2191
+ return false;
2192
+ }
2193
+ }
2194
+ /**
2195
+ * Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
2196
+ * — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
2197
+ * null when indeterminate (no api url, the request failed, or the box is an
2198
+ * unclaimed pool sandbox whose token the route would 403) so the shared
1697
2199
  * poller keeps the last-known values rather than flipping on a transient error.
1698
2200
  * This is the single fetch behind `feature-flags-poll.ts`; extensions read the
1699
2201
  * polled values there instead of issuing their own GET.
@@ -1701,6 +2203,7 @@ function sandboxClient() {
1701
2203
  async function fetchHarnessFlags() {
1702
2204
  const client = sandboxClient();
1703
2205
  if (!client) return null;
2206
+ if (await isPoolIdentity()) return null;
1704
2207
  try {
1705
2208
  const res = await client["feature-flags"].$get();
1706
2209
  if (!res.ok) {
@@ -1710,11 +2213,7 @@ async function fetchHarnessFlags() {
1710
2213
  }, "feature-flags fetch failed");
1711
2214
  return null;
1712
2215
  }
1713
- const body = await res.json();
1714
- return {
1715
- contextManagement: body.contextManagement ?? null,
1716
- subagent: body.subagent ?? null
1717
- };
2216
+ return { contextManagement: (await res.json()).contextManagement ?? null };
1718
2217
  } catch (err) {
1719
2218
  log$10.debug({
1720
2219
  err,
@@ -1772,8 +2271,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
1772
2271
  messageId,
1773
2272
  tasks
1774
2273
  } });
1775
- if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
1776
- return { taskIds: (await res.json()).taskIds };
2274
+ if (!res.ok) {
2275
+ let detail = "";
2276
+ try {
2277
+ const errBody = await res.json();
2278
+ if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
2279
+ } catch {}
2280
+ throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
2281
+ }
2282
+ const body = await res.json();
2283
+ return {
2284
+ taskIds: body.taskIds,
2285
+ tasks: body.tasks ?? []
2286
+ };
1777
2287
  }
1778
2288
  function createHeartbeatThrottle({ messageId }) {
1779
2289
  let lastAt = 0;
@@ -1922,20 +2432,16 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1922
2432
  * Shared harness feature-flag poll.
1923
2433
  *
1924
2434
  * The api exposes one `/feature-flags` GET that returns every harness flag in a
1925
- * single response (`{ contextManagement, subagent, commandFlags }` — see
2435
+ * single response (`{ contextManagement, commandFlags }` — see
1926
2436
  * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1927
2437
  * extension issuing its own GET — and, worse, a *blocking* GET on the
1928
2438
  * pre-first-token `session_start` path — a single background poller fetches
1929
2439
  * that response once per interval and fans the values out to every subscriber.
1930
2440
  *
1931
- * Why one poller: the subagent extension gates its tool registration on the
1932
- * `subagent` flag. If it awaited a fresh GET inside `session_start` the tool
1933
- * schema (part of the prefill) couldn't be finalized until a serial
1934
- * sandbox→api round-trip settled, adding a net-new pre-token network hop on
1935
- * every session, flag on or off. Reading the last-polled value instead keeps
1936
- * the hot path allocation-only. A cold cache reads as `null` (fail-open to
1937
- * unregistered); a newly-flipped flag takes effect on the next poll, matching
1938
- * how context-management already treats its flag.
2441
+ * Why one poller: context-management consumes the `contextManagement` flag
2442
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
2443
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
2444
+ * as `null` and a newly-flipped flag takes effect on the next poll.
1939
2445
  *
1940
2446
  * The poll is fire-and-forget and self-unref'd — it never keeps the process
1941
2447
  * alive and an indeterminate result (no api url / transient failure) leaves the
@@ -1944,15 +2450,11 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1944
2450
  const log$9 = logger.child({ module: "feature-flags-poll" });
1945
2451
  const FLAG_POLL_INTERVAL_MS = 6e4;
1946
2452
  let contextManagement = null;
1947
- let subagent = null;
1948
- const subscribers = {
1949
- contextManagement: /* @__PURE__ */ new Set(),
1950
- subagent: /* @__PURE__ */ new Set()
1951
- };
2453
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
1952
2454
  let pollerStarted = false;
1953
2455
  let firstPollSettled = false;
1954
2456
  let resolveFirstPoll = null;
1955
- const firstPollPromise = new Promise((resolve) => {
2457
+ new Promise((resolve) => {
1956
2458
  resolveFirstPoll = resolve;
1957
2459
  });
1958
2460
  function markFirstPollSettled() {
@@ -1961,20 +2463,8 @@ function markFirstPollSettled() {
1961
2463
  resolveFirstPoll?.();
1962
2464
  }
1963
2465
  /** Last-polled value of a flag, or `null` if not yet resolved. */
1964
- function getPolledFlag(name) {
1965
- return name === "contextManagement" ? contextManagement : subagent;
1966
- }
1967
- /**
1968
- * Await the first poll already kicked by `startFeatureFlagPoller` (never a new
1969
- * GET). Resolves when that poll settles, immediately if it already has, or
1970
- * immediately when there's no flag source to poll. Callers on the hot path
1971
- * should race this against their own short timeout so a slow/failed flag
1972
- * service cannot delay first-token; a timeout just means the caller reads the
1973
- * still-cold cache and falls back to its default, exactly as before.
1974
- */
1975
- function awaitFirstFlagPoll() {
1976
- if (firstPollSettled || !hasFlagSource()) return Promise.resolve();
1977
- return firstPollPromise;
2466
+ function getPolledFlag(_name) {
2467
+ return contextManagement;
1978
2468
  }
1979
2469
  /**
1980
2470
  * Subscribe to changes of a flag. The callback fires only on a *transition*
@@ -1987,9 +2477,8 @@ function onFlagChange(name, cb) {
1987
2477
  }
1988
2478
  function apply(name, next) {
1989
2479
  if (next === null) return;
1990
- const prev = name === "contextManagement" ? contextManagement : subagent;
1991
- if (name === "contextManagement") contextManagement = next;
1992
- else subagent = next;
2480
+ const prev = contextManagement;
2481
+ contextManagement = next;
1993
2482
  if (next !== prev) for (const cb of subscribers[name]) try {
1994
2483
  cb(next);
1995
2484
  } catch (err) {
@@ -2004,7 +2493,6 @@ async function pollOnce() {
2004
2493
  const flags = await fetchHarnessFlags();
2005
2494
  if (!flags) return;
2006
2495
  apply("contextManagement", flags.contextManagement ?? null);
2007
- apply("subagent", flags.subagent ?? null);
2008
2496
  } catch (err) {
2009
2497
  log$9.debug({ err }, "feature-flag poll threw");
2010
2498
  }
@@ -2782,7 +3270,6 @@ const soulExtension = (pi) => {
2782
3270
  //#endregion
2783
3271
  //#region src/extensions/subagent/index.ts
2784
3272
  const log$2 = logger.child({ module: "subagent-ext" });
2785
- const COLD_START_FLAG_WAIT_MS = 750;
2786
3273
  const MAX_TASKS = 8;
2787
3274
  const TaskItem = Type.Object({
2788
3275
  task: Type.String({ description: "The task to delegate to a subagent run." }),
@@ -2791,8 +3278,14 @@ const TaskItem = Type.Object({
2791
3278
  maxLength: 120
2792
3279
  }),
2793
3280
  persona: Type.Optional(Type.String({ description: "Optional extra system prompt / role for this task, applied ON TOP of the child run's own default persona (your full identity and soul are still there underneath). Omit to run with just your default persona." })),
2794
- model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on (e.g. \"anthropic/claude-opus-4-8\"). Must be a real catalogued model. Omit to run on your own model. If you are locked to a Google-compliant model, only compliant models are accepted." }))
3281
+ model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on. PREFER A LOWER-COST, FASTER MODEL when the task is well-scoped and does not need your full reasoning depth — most delegated subtasks (searching, summarizing, mechanical edits, gathering or reformatting data, running a check) run just as well on a lighter model and cost far less. Reserve a top-tier model for subtasks that genuinely need deep reasoning or careful judgment. Must be a real catalogued model id. Omit to inherit your own model. If you are locked to a Google-compliant model, only compliant models are accepted." })),
3282
+ timeoutMinutes: Type.Optional(Type.Integer({
3283
+ description: "Optional wall-clock timeout for this subagent, in minutes. If the run is still going after this long it is ended and you are rewoken with a timeout result, so a hung subagent can never strand you. Omit for the default (30 minutes). Raise it for genuinely long work (a big migration, a large audit); lower it for a quick lookup. Range 1-360.",
3284
+ minimum: 1,
3285
+ maximum: 360
3286
+ }))
2795
3287
  });
3288
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2796
3289
  const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2797
3290
  description: "One or more tasks to delegate. Each spawns an isolated subagent run linked to this conversation; they run in parallel and each rewakes you with its result when it finishes.",
2798
3291
  minItems: 1,
@@ -2807,7 +3300,9 @@ function buildTool(messageId) {
2807
3300
  "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2808
3301
  "Use it to parallelize independent work, to keep a large or noisy subtask out of your own context, or to run a task under a specialized persona.",
2809
3302
  "Fire-and-forget: this returns immediately after queueing. It does NOT wait for results. Each subagent runs on its own and, when it finishes, sends you its result on this thread — so queue the work, then keep going or end your turn. To chain, re-delegate after a result lands.",
2810
- "Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task; omit it to run on your own model."
3303
+ "Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task — prefer a lower-cost, faster model for well-scoped subtasks that don't need deep reasoning, and reserve a top-tier model for the ones that do; omit it to inherit your own model.",
3304
+ "Peering: each queued task comes back with its own conversation id. A subagent is a real linked conversation, so to see what one is doing RIGHT NOW while it runs — its reasoning, the tools it has called and their results, its progress — read that conversation with `platform conversations show <conversationId>` (you are already authorized; it is your own delegated run). Check in that way instead of waiting blind for the final result. The read reflects the child's persisted state, which lags a few seconds behind live (tool results land as they complete; in-progress reasoning can be up to ~5s stale), so peek between checkpoints rather than polling in a tight loop.",
3305
+ "Steering: to add context, correct course, or answer a question a subagent needs mid-run, post to its conversation with `platform conversations post <conversationId> --message \"...\"`. If the subagent is still running, your message lands as a live steer picked up in that same turn; if it has gone idle, it queues as its next turn. This is the same primitive as any conversation message — there is no separate steer channel."
2811
3306
  ].join(" "),
2812
3307
  promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2813
3308
  parameters: SubagentParams,
@@ -2825,24 +3320,35 @@ function buildTool(messageId) {
2825
3320
  task: t.task,
2826
3321
  title: t.title ?? null,
2827
3322
  persona: t.persona ?? null,
2828
- model: t.model ?? null
3323
+ model: t.model ?? null,
3324
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2829
3325
  }));
2830
3326
  try {
2831
- const { taskIds } = await postSubagentSpawn({
3327
+ const spawned = await postSubagentSpawn({
2832
3328
  messageId,
2833
3329
  tasks: spawnTasks
2834
3330
  });
3331
+ const { taskIds } = spawned;
2835
3332
  log$2.info({
2836
3333
  event: "subagent_spawned",
2837
3334
  count: taskIds.length
2838
3335
  }, "subagent tasks queued");
2839
- const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? ""}`).join("\n");
3336
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
3337
+ const lines = taskIds.map((id, i) => {
3338
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
3339
+ const conv = convByTask.get(id);
3340
+ return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
3341
+ }).join("\n");
3342
+ const peerHint = spawned.tasks.length ? "\nEach subagent runs on its own conversation (id shown per task above). To SEE what one is doing while it runs, read it with `platform conversations show <conversationId>`. To STEER one mid-run — add context, correct course, answer a question — post to its conversation with `platform conversations post <conversationId> --message \"...\"`; it lands as a live steer if the subagent is still running, or as its next turn if it has gone idle." : "";
2840
3343
  return {
2841
3344
  content: [{
2842
3345
  type: "text",
2843
- text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}`
3346
+ text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}${peerHint}`
2844
3347
  }],
2845
- details: { taskIds }
3348
+ details: {
3349
+ taskIds,
3350
+ tasks: spawned.tasks
3351
+ }
2846
3352
  };
2847
3353
  } catch (err) {
2848
3354
  const message = err instanceof Error ? err.message : String(err);
@@ -2863,16 +3369,15 @@ function buildTool(messageId) {
2863
3369
  };
2864
3370
  }
2865
3371
  /**
2866
- * Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
2867
3372
  * The factory takes the session's channel context to resolve the originating
2868
3373
  * messageId — the api links each spawned run to the conversation that message
2869
3374
  * belongs to and rewakes it on completion (nothing about the parent is piped
2870
- * from the sandbox beyond that id).
3375
+ * from the sandbox beyond that id). The tool is registered unconditionally at
3376
+ * session_start.
2871
3377
  */
2872
3378
  function createSubagentExtension({ channelContext }) {
2873
3379
  return (pi) => {
2874
3380
  const messageId = extractMessageId(channelContext);
2875
- startFeatureFlagPoller();
2876
3381
  let registered = false;
2877
3382
  const registerOnce = () => {
2878
3383
  if (registered) return;
@@ -2880,12 +3385,8 @@ function createSubagentExtension({ channelContext }) {
2880
3385
  pi.registerTool(buildTool(messageId));
2881
3386
  log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
2882
3387
  };
2883
- onFlagChange("subagent", (enabled) => {
2884
- if (enabled) registerOnce();
2885
- });
2886
- pi.on("session_start", async () => {
2887
- if (getPolledFlag("subagent") === null) await Promise.race([awaitFirstFlagPoll(), new Promise((resolve) => setTimeout(resolve, COLD_START_FLAG_WAIT_MS).unref?.())]);
2888
- if (getPolledFlag("subagent") === true) registerOnce();
3388
+ pi.on("session_start", () => {
3389
+ registerOnce();
2889
3390
  });
2890
3391
  };
2891
3392
  }
@@ -3163,8 +3664,14 @@ const toolCallSummaryExtension = (pi) => {
3163
3664
  * from the origin messageId in its channel context (`resolveConversationFromApi`)
3164
3665
  * — and every run of the same conversation resolves to the same id, keeping the
3165
3666
  * shared map correctly scoped across turns. `bg_*`, the completion wake, and the
3166
- * next-session injection all filter to the resolved conversationan agent
3167
- * never sees or is woken by a task from a different chat. Only the output log
3667
+ * next-session injection all filter by a **scope key**the resolved
3668
+ * conversation id, or, when a session's conversation is unresolvable (a bare
3669
+ * CLI session, or a run whose channel-context ref carries no messageId), a
3670
+ * sentinel unique to that one session instance. Comparing on the raw
3671
+ * `conversationId` would bucket every unresolvable session together under
3672
+ * `null` and leak one's completion wake / status / next-session injection into
3673
+ * another; the sentinel keeps each isolated so an agent never sees or is woken
3674
+ * by a task from a different chat. Only the output log
3168
3675
  * spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
3169
3676
  * in memory; exit code and run state live on the in-memory task.
3170
3677
  *
@@ -3195,11 +3702,12 @@ function taskLabel(meta) {
3195
3702
  return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
3196
3703
  }
3197
3704
  let taskCounter = 0;
3705
+ let sessionScopeCounter = 0;
3198
3706
  const tasks = /* @__PURE__ */ new Map();
3199
3707
  let watchdogInterval = null;
3200
3708
  let lastKeepaliveAt = 0;
3201
- function sameConversation(meta, conversationId) {
3202
- return meta.conversationId === conversationId;
3709
+ function sameScope(meta, scopeKey) {
3710
+ return meta.scopeKey === scopeKey;
3203
3711
  }
3204
3712
  function logPath(id) {
3205
3713
  return join(tasksDir(), `${id}.log`);
@@ -3312,6 +3820,10 @@ function createBackgroundTasksExtension({ channelContext }) {
3312
3820
  return id;
3313
3821
  });
3314
3822
  }
3823
+ const unresolvedScopeSentinel = `unresolved:${process.pid.toString(36)}:${(sessionScopeCounter += 1).toString(36)}`;
3824
+ function scopeKey() {
3825
+ return conversationId ?? unresolvedScopeSentinel;
3826
+ }
3315
3827
  let agentActive = false;
3316
3828
  pi.on("agent_start", async () => {
3317
3829
  agentActive = true;
@@ -3345,7 +3857,7 @@ This is a background-task completion, not a message from the user. If it needs n
3345
3857
  }
3346
3858
  async function notifyCompletion(meta) {
3347
3859
  if (meta.notified) return;
3348
- if (agentActive && sameConversation(meta, conversationId)) {
3860
+ if (agentActive && sameScope(meta, scopeKey())) {
3349
3861
  meta.notified = true;
3350
3862
  pi.sendMessage(await taskDoneMessage(meta), {
3351
3863
  triggerTurn: true,
@@ -3402,6 +3914,7 @@ This is a background-task completion, not a message from the user. If it needs n
3402
3914
  logBytes: 0,
3403
3915
  lastOutputAt: startedAt,
3404
3916
  conversationId,
3917
+ scopeKey: scopeKey(),
3405
3918
  messageId,
3406
3919
  description,
3407
3920
  notified: false,
@@ -3447,16 +3960,16 @@ This is a background-task completion, not a message from the user. If it needs n
3447
3960
  return meta;
3448
3961
  }
3449
3962
  function knownTaskIds() {
3450
- return [...tasks.values()].filter((t) => sameConversation(t, conversationId)).map((t) => t.id).join(", ") || "(none)";
3963
+ return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
3451
3964
  }
3452
3965
  pi.on("session_start", async () => {
3453
3966
  if (tasks.size === 0) return;
3454
3967
  await ensureConversationId();
3455
- for (const [id, meta] of tasks) if (sameConversation(meta, conversationId) && !meta.running && meta.notified) {
3968
+ for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
3456
3969
  tasks.delete(id);
3457
3970
  await unlink(logPath(id)).catch(() => {});
3458
3971
  }
3459
- const unnotified = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && !t.notified && !t.running);
3972
+ const unnotified = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && !t.notified && !t.running);
3460
3973
  for (const meta of unnotified) {
3461
3974
  meta.notified = true;
3462
3975
  pi.sendMessage(await taskDoneMessage(meta));
@@ -3465,7 +3978,7 @@ This is a background-task completion, not a message from the user. If it needs n
3465
3978
  conversationId,
3466
3979
  count: unnotified.length
3467
3980
  }, "injected completed bg tasks at session_start");
3468
- if ([...tasks.values()].some((t) => sameConversation(t, conversationId) && t.running)) ensureWatchdog();
3981
+ if ([...tasks.values()].some((t) => sameScope(t, scopeKey()) && t.running)) ensureWatchdog();
3469
3982
  });
3470
3983
  function err(text) {
3471
3984
  return {
@@ -3482,11 +3995,11 @@ This is a background-task completion, not a message from the user. If it needs n
3482
3995
  }
3483
3996
  function resolveTask(taskId) {
3484
3997
  const exact = tasks.get(taskId);
3485
- if (exact && sameConversation(exact, conversationId)) return {
3998
+ if (exact && sameScope(exact, scopeKey())) return {
3486
3999
  error: null,
3487
4000
  meta: exact
3488
4001
  };
3489
- const matches = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && t.id.startsWith(taskId));
4002
+ const matches = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && t.id.startsWith(taskId));
3490
4003
  if (matches.length === 1) return {
3491
4004
  error: null,
3492
4005
  meta: matches[0]
@@ -3495,7 +4008,7 @@ This is a background-task completion, not a message from the user. If it needs n
3495
4008
  return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
3496
4009
  }
3497
4010
  function listTasks() {
3498
- const mine = [...tasks.values()].filter((t) => sameConversation(t, conversationId));
4011
+ const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
3499
4012
  if (mine.length === 0) return "No background tasks.";
3500
4013
  return mine.map((t) => {
3501
4014
  const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
@@ -3645,6 +4158,7 @@ const all = [
3645
4158
  localToolsExtension,
3646
4159
  toolCallEnvExtension,
3647
4160
  bashDefaultTimeoutExtension,
4161
+ diskGuardExtension,
3648
4162
  toolCallSummaryExtension
3649
4163
  ];
3650
4164
  /**
@@ -3663,7 +4177,8 @@ function platformExtensions({ sessionId, channelContext }) {
3663
4177
  selfTraceExtension,
3664
4178
  createBackgroundTasksExtension({ channelContext }),
3665
4179
  createSubagentExtension({ channelContext }),
3666
- createContextManagementExtension()
4180
+ createContextManagementExtension(),
4181
+ resourcePressureWarningExtension
3667
4182
  ];
3668
4183
  }
3669
4184
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.150",
3
+ "version": "0.1.0-beta.1502",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",
@@ -17,20 +17,13 @@
17
17
  },
18
18
  "publishConfig": {
19
19
  "access": "public",
20
- "exports": {
21
- ".": {
22
- "types": "./dist/index.d.mts",
23
- "default": "./dist/index.mjs"
24
- }
25
- },
26
20
  "registry": "https://registry.npmjs.org"
27
21
  },
28
22
  "scripts": {
29
23
  "build": "tsdown",
30
24
  "typecheck": "tsgo --noEmit",
31
25
  "test:unit": "vitest run --passWithNoTests",
32
- "test:ci": "vitest run --coverage --coverage.reporter=lcovonly --reporter=default --reporter=github-actions --minWorkers=1 --maxWorkers=2 --passWithNoTests",
33
- "publish:system-artifacts": "doppler run --preserve-env -- node ../../scripts/anyone/publish-system-artifact.mjs"
26
+ "test:ci": "vitest run --coverage --coverage.reporter=lcovonly --reporter=default --reporter=github-actions --minWorkers=1 --maxWorkers=2 --passWithNoTests"
34
27
  },
35
28
  "dependencies": {
36
29
  "@a2a-js/sdk": "^0.3.13",