@skydiveai/pi-extensions 0.1.0-beta.113 → 0.1.0-beta.1132

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 +457 -114
  2. package/package.json +1 -7
package/dist/index.mjs CHANGED
@@ -21,6 +21,9 @@ import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace
21
21
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
22
22
  import { hc } from "hono/client";
23
23
  import { parse } from "yaml";
24
+ import { execFile } from "node:child_process";
25
+ import { availableParallelism } from "node:os";
26
+ import { promisify } from "node:util";
24
27
  import { quote } from "shell-quote";
25
28
  import { createWriteStream } from "node:fs";
26
29
  import { finished } from "node:stream/promises";
@@ -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$14 = 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$14.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$13 = 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$13.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$13.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$13.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$13.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$12 = 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$12.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$12.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$12.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$12.error({
1280
1354
  err,
1281
1355
  event: "mcp_auto_reload_failed"
1282
1356
  }, "auto-reload after mcp.config.json change failed");
@@ -1641,14 +1715,14 @@ const bashDefaultTimeoutExtension = (pi) => {
1641
1715
  //#endregion
1642
1716
  //#region src/channel-context-ref.ts
1643
1717
  /**
1644
- * The worker injects only a reference — `{ channel, messageId }` — into the
1645
- * sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
1718
+ * The worker injects a small reference — `{ channel, messageId, runId }` —
1719
+ * into the sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
1646
1720
  *
1647
1721
  * The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
1648
1722
  * `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
1649
1723
  * `@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.
1724
+ * platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
1725
+ * field. So we validate the field this consumer needs locally instead.
1652
1726
  */
1653
1727
  const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
1654
1728
  /**
@@ -1680,20 +1754,77 @@ function apiBaseUrl() {
1680
1754
  }
1681
1755
  //#endregion
1682
1756
  //#region src/extensions/platform.ts
1757
+ /**
1758
+ * Platform extension — bridges the agent harness to the Skydive platform daemon.
1759
+ *
1760
+ * Responsibilities:
1761
+ * - Heartbeat: periodic POST to the API so the sandbox manager knows the
1762
+ * agent is alive. Throttled to once per minute, triggered by tool events.
1763
+ * - Session tracking: registers the session with the daemon on start,
1764
+ * streams tool_call / tool_result events so the daemon can track which
1765
+ * session is actively executing, and signals session end on agent_end.
1766
+ * - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
1767
+ * messageId) to the daemon so file writes can be attributed to the
1768
+ * correct conversation.
1769
+ *
1770
+ * All daemon POSTs are fire-and-forget — failures are logged but never
1771
+ * block the agent. The daemon may not be running (e.g. local dev without
1772
+ * a sandbox), and that's fine.
1773
+ */
1683
1774
  const HEARTBEAT_THROTTLE_MS = 6e4;
1684
1775
  const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
1685
1776
  const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
1686
1777
  const DAEMON_URL = "http://localhost:38994";
1687
- const log$10 = logger.child({ module: "platform-ext" });
1778
+ const log$11 = logger.child({ module: "platform-ext" });
1688
1779
  function sandboxClient() {
1689
1780
  const apiUrl = apiBaseUrl();
1690
1781
  if (!apiUrl) return null;
1691
1782
  return hc(`${apiUrl}/api/v1/sandbox`);
1692
1783
  }
1693
1784
  /**
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
1785
+ * Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
1786
+ * feature-flags half of the ANY-5184 pool 403 wave.)
1787
+ *
1788
+ * `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
1789
+ * from a pool box can only 403 — a guaranteed-failing GET every 60s for the
1790
+ * life of the pool phase. The discriminator is the sandbox token's `type`
1791
+ * claim, read UNVERIFIED (this box never holds the signing secret): not an
1792
+ * authorization decision, only "should I bother calling?", and the api still
1793
+ * authorizes every request.
1794
+ *
1795
+ * Read per call from the daemon's persisted env file, NOT process.env:
1796
+ * claiming a pool box rebinds the token in place (the daemon rewrites this
1797
+ * file) while the harness's process.env keeps the boot snapshot, so a
1798
+ * process-env gate would leave a claimed box permanently skipping — trading a
1799
+ * wasted request for silently frozen flags, which is strictly worse. "Cannot
1800
+ * tell" (no file, no token, unparseable payload) reports false so the poll
1801
+ * proceeds.
1802
+ */
1803
+ const daemonEnvIdentitySchema = z.object({
1804
+ ANYONE_SANDBOX_TOKEN: z.string().optional(),
1805
+ SKYDIVE_SANDBOX_TOKEN: z.string().optional()
1806
+ }).passthrough();
1807
+ const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
1808
+ async function isPoolIdentity() {
1809
+ try {
1810
+ const envFile = process.env.ANYONE_DAEMON_ENV_CACHE ?? "/tmp/.anyone/daemon-env.json";
1811
+ const env = daemonEnvIdentitySchema.safeParse(JSON.parse(await readFile(envFile, "utf8")));
1812
+ if (!env.success) return false;
1813
+ const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
1814
+ if (typeof token !== "string" || token === "") return false;
1815
+ const payload = token.split(".")[1];
1816
+ if (!payload) return false;
1817
+ const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
1818
+ return claims.success && claims.data.type === "onboarding-pool";
1819
+ } catch (_err) {
1820
+ return false;
1821
+ }
1822
+ }
1823
+ /**
1824
+ * Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
1825
+ * — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1826
+ * null when indeterminate (no api url, the request failed, or the box is an
1827
+ * unclaimed pool sandbox whose token the route would 403) so the shared
1697
1828
  * poller keeps the last-known values rather than flipping on a transient error.
1698
1829
  * This is the single fetch behind `feature-flags-poll.ts`; extensions read the
1699
1830
  * polled values there instead of issuing their own GET.
@@ -1701,22 +1832,19 @@ function sandboxClient() {
1701
1832
  async function fetchHarnessFlags() {
1702
1833
  const client = sandboxClient();
1703
1834
  if (!client) return null;
1835
+ if (await isPoolIdentity()) return null;
1704
1836
  try {
1705
1837
  const res = await client["feature-flags"].$get();
1706
1838
  if (!res.ok) {
1707
- log$10.debug({
1839
+ log$11.debug({
1708
1840
  status: res.status,
1709
1841
  event: "feature_flags_fetch_failed"
1710
1842
  }, "feature-flags fetch failed");
1711
1843
  return null;
1712
1844
  }
1713
- const body = await res.json();
1714
- return {
1715
- contextManagement: body.contextManagement ?? null,
1716
- subagent: body.subagent ?? null
1717
- };
1845
+ return { contextManagement: (await res.json()).contextManagement ?? null };
1718
1846
  } catch (err) {
1719
- log$10.debug({
1847
+ log$11.debug({
1720
1848
  err,
1721
1849
  event: "feature_flags_fetch_error"
1722
1850
  }, "feature-flags request errored");
@@ -1727,7 +1855,7 @@ function postHeartbeat({ messageId }) {
1727
1855
  const client = sandboxClient();
1728
1856
  if (!client) return;
1729
1857
  client.heartbeat.$post({ json: { messageId } }).catch((err) => {
1730
- log$10.debug({
1858
+ log$11.debug({
1731
1859
  err,
1732
1860
  event: "heartbeat_failed"
1733
1861
  }, "heartbeat failed");
@@ -1739,7 +1867,7 @@ async function resolveConversationFromApi(messageId) {
1739
1867
  try {
1740
1868
  const res = await client["message-conversation"].$get({ query: { messageId } });
1741
1869
  if (!res.ok) {
1742
- log$10.warn({
1870
+ log$11.warn({
1743
1871
  status: res.status,
1744
1872
  messageId,
1745
1873
  event: "resolve_conversation_failed"
@@ -1748,7 +1876,7 @@ async function resolveConversationFromApi(messageId) {
1748
1876
  }
1749
1877
  return (await res.json()).conversationId ?? null;
1750
1878
  } catch (err) {
1751
- log$10.warn({
1879
+ log$11.warn({
1752
1880
  err,
1753
1881
  messageId,
1754
1882
  event: "resolve_conversation_error"
@@ -1772,8 +1900,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
1772
1900
  messageId,
1773
1901
  tasks
1774
1902
  } });
1775
- if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
1776
- return { taskIds: (await res.json()).taskIds };
1903
+ if (!res.ok) {
1904
+ let detail = "";
1905
+ try {
1906
+ const errBody = await res.json();
1907
+ if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
1908
+ } catch {}
1909
+ throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
1910
+ }
1911
+ const body = await res.json();
1912
+ return {
1913
+ taskIds: body.taskIds,
1914
+ tasks: body.tasks ?? []
1915
+ };
1777
1916
  }
1778
1917
  function createHeartbeatThrottle({ messageId }) {
1779
1918
  let lastAt = 0;
@@ -1822,7 +1961,7 @@ function createToolHeartbeat({ messageId }) {
1822
1961
  }
1823
1962
  heartbeatCount++;
1824
1963
  if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
1825
- log$10.warn({
1964
+ log$11.warn({
1826
1965
  heartbeatCount,
1827
1966
  activeToolCalls: [...activeToolCalls]
1828
1967
  }, "tool heartbeat max reached, stopping");
@@ -1853,7 +1992,7 @@ function postToDaemon(path, body) {
1853
1992
  headers: { "content-type": "application/json" },
1854
1993
  body: JSON.stringify(body)
1855
1994
  }).catch((err) => {
1856
- log$10.debug({
1995
+ log$11.debug({
1857
1996
  err,
1858
1997
  path,
1859
1998
  event: "daemon_post_failed"
@@ -1862,7 +2001,7 @@ function postToDaemon(path, body) {
1862
2001
  }
1863
2002
  function createPlatformExtensions({ sessionId, channelContext }) {
1864
2003
  return (pi) => {
1865
- log$10.info({
2004
+ log$11.info({
1866
2005
  sessionId,
1867
2006
  hasChannelContext: Boolean(channelContext)
1868
2007
  }, "platform extension initialized");
@@ -1911,7 +2050,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1911
2050
  });
1912
2051
  });
1913
2052
  pi.on("agent_end", () => {
1914
- log$10.info({ sessionId }, "session ending");
2053
+ log$11.info({ sessionId }, "session ending");
1915
2054
  postToDaemon("/session/end", { sessionId });
1916
2055
  });
1917
2056
  };
@@ -1922,37 +2061,29 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1922
2061
  * Shared harness feature-flag poll.
1923
2062
  *
1924
2063
  * The api exposes one `/feature-flags` GET that returns every harness flag in a
1925
- * single response (`{ contextManagement, subagent, commandFlags }` — see
2064
+ * single response (`{ contextManagement, commandFlags }` — see
1926
2065
  * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1927
2066
  * extension issuing its own GET — and, worse, a *blocking* GET on the
1928
2067
  * pre-first-token `session_start` path — a single background poller fetches
1929
2068
  * that response once per interval and fans the values out to every subscriber.
1930
2069
  *
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.
2070
+ * Why one poller: context-management consumes the `contextManagement` flag
2071
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
2072
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
2073
+ * as `null` and a newly-flipped flag takes effect on the next poll.
1939
2074
  *
1940
2075
  * The poll is fire-and-forget and self-unref'd — it never keeps the process
1941
2076
  * alive and an indeterminate result (no api url / transient failure) leaves the
1942
2077
  * last-known values untouched so a blip can't silently flip behavior.
1943
2078
  */
1944
- const log$9 = logger.child({ module: "feature-flags-poll" });
2079
+ const log$10 = logger.child({ module: "feature-flags-poll" });
1945
2080
  const FLAG_POLL_INTERVAL_MS = 6e4;
1946
2081
  let contextManagement = null;
1947
- let subagent = null;
1948
- const subscribers = {
1949
- contextManagement: /* @__PURE__ */ new Set(),
1950
- subagent: /* @__PURE__ */ new Set()
1951
- };
2082
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
1952
2083
  let pollerStarted = false;
1953
2084
  let firstPollSettled = false;
1954
2085
  let resolveFirstPoll = null;
1955
- const firstPollPromise = new Promise((resolve) => {
2086
+ new Promise((resolve) => {
1956
2087
  resolveFirstPoll = resolve;
1957
2088
  });
1958
2089
  function markFirstPollSettled() {
@@ -1961,20 +2092,8 @@ function markFirstPollSettled() {
1961
2092
  resolveFirstPoll?.();
1962
2093
  }
1963
2094
  /** 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;
2095
+ function getPolledFlag(_name) {
2096
+ return contextManagement;
1978
2097
  }
1979
2098
  /**
1980
2099
  * Subscribe to changes of a flag. The callback fires only on a *transition*
@@ -1987,13 +2106,12 @@ function onFlagChange(name, cb) {
1987
2106
  }
1988
2107
  function apply(name, next) {
1989
2108
  if (next === null) return;
1990
- const prev = name === "contextManagement" ? contextManagement : subagent;
1991
- if (name === "contextManagement") contextManagement = next;
1992
- else subagent = next;
2109
+ const prev = contextManagement;
2110
+ contextManagement = next;
1993
2111
  if (next !== prev) for (const cb of subscribers[name]) try {
1994
2112
  cb(next);
1995
2113
  } catch (err) {
1996
- log$9.warn({
2114
+ log$10.warn({
1997
2115
  err,
1998
2116
  flag: name
1999
2117
  }, "flag subscriber threw");
@@ -2004,9 +2122,8 @@ async function pollOnce() {
2004
2122
  const flags = await fetchHarnessFlags();
2005
2123
  if (!flags) return;
2006
2124
  apply("contextManagement", flags.contextManagement ?? null);
2007
- apply("subagent", flags.subagent ?? null);
2008
2125
  } catch (err) {
2009
- log$9.debug({ err }, "feature-flag poll threw");
2126
+ log$10.debug({ err }, "feature-flag poll threw");
2010
2127
  }
2011
2128
  }
2012
2129
  /**
@@ -2113,7 +2230,7 @@ function transformContextMessages(messages, config, now) {
2113
2230
  }
2114
2231
  //#endregion
2115
2232
  //#region src/extensions/context-management.ts
2116
- const log$8 = logger.child({ module: "context-management-extension" });
2233
+ const log$9 = logger.child({ module: "context-management-extension" });
2117
2234
  function isAnthropicMessagesPayload(payload) {
2118
2235
  if (typeof payload !== "object" || payload === null) return false;
2119
2236
  const candidate = payload;
@@ -2178,13 +2295,13 @@ function createContextManagementExtension() {
2178
2295
  setContextManagementFlagOverride(getPolledFlag("contextManagement"));
2179
2296
  onFlagChange("contextManagement", (enabled) => {
2180
2297
  setContextManagementFlagOverride(enabled);
2181
- log$8.info({
2298
+ log$9.info({
2182
2299
  event: "context_management_flag_update",
2183
2300
  enabled
2184
2301
  }, "context-management flag updated from platform");
2185
2302
  });
2186
2303
  startFeatureFlagPoller();
2187
- log$8.info({
2304
+ log$9.info({
2188
2305
  event: "context_management_registered",
2189
2306
  enabled: initial.enabled,
2190
2307
  flagSource: hasFlagSource(),
@@ -2196,13 +2313,13 @@ function createContextManagementExtension() {
2196
2313
  const { messages } = event;
2197
2314
  try {
2198
2315
  const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
2199
- if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$8.info({
2316
+ if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$9.info({
2200
2317
  event: "context_management_applied",
2201
2318
  ...result.stats
2202
2319
  }, "trimmed/cleared tool output before LLM call");
2203
2320
  return { messages: result.messages };
2204
2321
  } catch (err) {
2205
- log$8.error({
2322
+ log$9.error({
2206
2323
  err,
2207
2324
  event: "context_management_transform_failed"
2208
2325
  }, "context transform failed; passing messages through unchanged");
@@ -2214,7 +2331,7 @@ function createContextManagementExtension() {
2214
2331
  }
2215
2332
  //#endregion
2216
2333
  //#region src/extensions/current-time.ts
2217
- const log$7 = logger.child({ module: "current-time-extension" });
2334
+ const log$8 = logger.child({ module: "current-time-extension" });
2218
2335
  const PI_DATE_LINE = /^Current date:.*$/m;
2219
2336
  function formatCurrentTimeLine(now) {
2220
2337
  return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
@@ -2227,7 +2344,7 @@ const currentTimeExtension = (pi) => {
2227
2344
  const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
2228
2345
  const base = event.systemPrompt;
2229
2346
  if (PI_DATE_LINE.test(base)) {
2230
- log$7.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
2347
+ log$8.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
2231
2348
  return { systemPrompt: base.replace(PI_DATE_LINE, line) };
2232
2349
  }
2233
2350
  return { systemPrompt: `${base}\n${line}` };
@@ -2464,7 +2581,7 @@ function renderIndex(entries) {
2464
2581
  }
2465
2582
  //#endregion
2466
2583
  //#region src/extensions/memory.ts
2467
- const log$6 = logger.child({ module: "memory-extension" });
2584
+ const log$7 = logger.child({ module: "memory-extension" });
2468
2585
  /**
2469
2586
  * The standing instructions for the memory system. Always injected (even with
2470
2587
  * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
@@ -2498,7 +2615,7 @@ const memoryExtension = (pi) => {
2498
2615
  index
2499
2616
  });
2500
2617
  } catch (err) {
2501
- log$6.warn({
2618
+ log$7.warn({
2502
2619
  err,
2503
2620
  event: "memory_index_failed"
2504
2621
  }, "memory index build failed; injecting instructions only");
@@ -2512,7 +2629,7 @@ const memoryExtension = (pi) => {
2512
2629
  };
2513
2630
  //#endregion
2514
2631
  //#region src/extensions/platform-memory.ts
2515
- const log$5 = logger.child({ module: "platform-memory-extension" });
2632
+ const log$6 = logger.child({ module: "platform-memory-extension" });
2516
2633
  /**
2517
2634
  * Resolve the human on this turn via the API, keyed by the message id.
2518
2635
  * `/sandbox/channel-context` only returns a sender for a platform-known
@@ -2525,13 +2642,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
2525
2642
  async function resolveTurnUser(messageId) {
2526
2643
  const client = sandboxClient();
2527
2644
  if (!client) {
2528
- log$5.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2645
+ log$6.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2529
2646
  return null;
2530
2647
  }
2531
2648
  try {
2532
2649
  const res = await client["channel-context"].$get({ query: { messageId } });
2533
2650
  if (!res.ok) {
2534
- log$5.warn({
2651
+ log$6.warn({
2535
2652
  event: "resolve_turn_user_failed",
2536
2653
  status: res.status
2537
2654
  }, "channel-context returned non-ok; withholding user memory");
@@ -2544,7 +2661,7 @@ async function resolveTurnUser(messageId) {
2544
2661
  displayName: sender.displayName
2545
2662
  };
2546
2663
  } catch (err) {
2547
- log$5.warn({
2664
+ log$6.warn({
2548
2665
  err,
2549
2666
  event: "resolve_turn_user_failed"
2550
2667
  }, "failed to resolve current user; withholding user memory");
@@ -2591,7 +2708,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2591
2708
  user
2592
2709
  });
2593
2710
  } catch (err) {
2594
- log$5.warn({
2711
+ log$6.warn({
2595
2712
  err,
2596
2713
  event: "user_memory_index_failed"
2597
2714
  }, "user memory index build failed; skipping injection");
@@ -2606,7 +2723,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2606
2723
  }
2607
2724
  //#endregion
2608
2725
  //#region src/extensions/self-trace.ts
2609
- const log$4 = logger.child({ module: "self-trace-extension" });
2726
+ const log$5 = logger.child({ module: "self-trace-extension" });
2610
2727
  /**
2611
2728
  * Reports the agent's own execution as OpenTelemetry spans:
2612
2729
  * agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
@@ -2631,7 +2748,7 @@ const selfTraceExtension = (pi) => {
2631
2748
  sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
2632
2749
  sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
2633
2750
  const sc = sessionSpan.spanContext();
2634
- log$4.info({
2751
+ log$5.info({
2635
2752
  event: "self_trace_session_start",
2636
2753
  trace_id: sc.traceId,
2637
2754
  span_id: sc.spanId,
@@ -2743,13 +2860,13 @@ const selfTraceExtension = (pi) => {
2743
2860
  * Lives in the harness package — soul.md is content from the agent's
2744
2861
  * own git repo, not from the platform — so its handling stays here.
2745
2862
  */
2746
- const log$3 = logger.child({ module: "soul-extension" });
2863
+ const log$4 = logger.child({ module: "soul-extension" });
2747
2864
  async function readSoul(cwd) {
2748
2865
  try {
2749
2866
  return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
2750
2867
  } catch (err) {
2751
2868
  if (err?.code === "ENOENT") return null;
2752
- log$3.warn({
2869
+ log$4.warn({
2753
2870
  err,
2754
2871
  event: "soul_read_failed"
2755
2872
  }, "soul.md read failed");
@@ -2763,7 +2880,7 @@ function soulSection(cwd, soul) {
2763
2880
 
2764
2881
  **\`soul.md\` is where behavior lives.** Any standing instruction about how you should act — a rule a user wants you to follow going forward, a tone or format preference, a workflow convention, a "from now on, always/never …" — belongs here, not in \`.memory/\`. Memory records *what happened* (facts, events, findings); soul defines *how you behave*. When a user gives you a durable behavioral rule, write it to \`soul.md\`. If you find behavioral rules that ended up in \`.memory/\`, treat that as misfiled and move them here.
2765
2882
 
2766
- Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Edit it (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
2883
+ Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect, a secret or auth credential you wire in — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Do this the moment you gain the capability, and for a credential that means the moment it verifies with a real call, not after a human points out that you forgot. Connecting a capability is itself a durable change worth recording, not merely a step toward the task in front of you. Edit \`soul.md\` (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
2767
2884
 
2768
2885
  ${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
2769
2886
  }
@@ -2781,8 +2898,7 @@ const soulExtension = (pi) => {
2781
2898
  };
2782
2899
  //#endregion
2783
2900
  //#region src/extensions/subagent/index.ts
2784
- const log$2 = logger.child({ module: "subagent-ext" });
2785
- const COLD_START_FLAG_WAIT_MS = 750;
2901
+ const log$3 = logger.child({ module: "subagent-ext" });
2786
2902
  const MAX_TASKS = 8;
2787
2903
  const TaskItem = Type.Object({
2788
2904
  task: Type.String({ description: "The task to delegate to a subagent run." }),
@@ -2791,8 +2907,14 @@ const TaskItem = Type.Object({
2791
2907
  maxLength: 120
2792
2908
  }),
2793
2909
  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." }))
2910
+ 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." })),
2911
+ timeoutMinutes: Type.Optional(Type.Integer({
2912
+ 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.",
2913
+ minimum: 1,
2914
+ maximum: 360
2915
+ }))
2795
2916
  });
2917
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2796
2918
  const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2797
2919
  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
2920
  minItems: 1,
@@ -2807,7 +2929,9 @@ function buildTool(messageId) {
2807
2929
  "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2808
2930
  "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
2931
  "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."
2932
+ "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.",
2933
+ "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.",
2934
+ "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
2935
  ].join(" "),
2812
2936
  promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2813
2937
  parameters: SubagentParams,
@@ -2825,28 +2949,39 @@ function buildTool(messageId) {
2825
2949
  task: t.task,
2826
2950
  title: t.title ?? null,
2827
2951
  persona: t.persona ?? null,
2828
- model: t.model ?? null
2952
+ model: t.model ?? null,
2953
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2829
2954
  }));
2830
2955
  try {
2831
- const { taskIds } = await postSubagentSpawn({
2956
+ const spawned = await postSubagentSpawn({
2832
2957
  messageId,
2833
2958
  tasks: spawnTasks
2834
2959
  });
2835
- log$2.info({
2960
+ const { taskIds } = spawned;
2961
+ log$3.info({
2836
2962
  event: "subagent_spawned",
2837
2963
  count: taskIds.length
2838
2964
  }, "subagent tasks queued");
2839
- const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? ""}`).join("\n");
2965
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
2966
+ const lines = taskIds.map((id, i) => {
2967
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
2968
+ const conv = convByTask.get(id);
2969
+ return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
2970
+ }).join("\n");
2971
+ 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
2972
  return {
2841
2973
  content: [{
2842
2974
  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}`
2975
+ 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
2976
  }],
2845
- details: { taskIds }
2977
+ details: {
2978
+ taskIds,
2979
+ tasks: spawned.tasks
2980
+ }
2846
2981
  };
2847
2982
  } catch (err) {
2848
2983
  const message = err instanceof Error ? err.message : String(err);
2849
- log$2.warn({
2984
+ log$3.warn({
2850
2985
  err,
2851
2986
  event: "subagent_spawn_failed"
2852
2987
  }, "subagent spawn failed");
@@ -2863,33 +2998,240 @@ function buildTool(messageId) {
2863
2998
  };
2864
2999
  }
2865
3000
  /**
2866
- * Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
2867
3001
  * The factory takes the session's channel context to resolve the originating
2868
3002
  * messageId — the api links each spawned run to the conversation that message
2869
3003
  * belongs to and rewakes it on completion (nothing about the parent is piped
2870
- * from the sandbox beyond that id).
3004
+ * from the sandbox beyond that id). The tool is registered unconditionally at
3005
+ * session_start.
2871
3006
  */
2872
3007
  function createSubagentExtension({ channelContext }) {
2873
3008
  return (pi) => {
2874
3009
  const messageId = extractMessageId(channelContext);
2875
- startFeatureFlagPoller();
2876
3010
  let registered = false;
2877
3011
  const registerOnce = () => {
2878
3012
  if (registered) return;
2879
3013
  registered = true;
2880
3014
  pi.registerTool(buildTool(messageId));
2881
- log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
3015
+ log$3.info({ event: "subagent_enabled" }, "subagent tool registered");
2882
3016
  };
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();
3017
+ pi.on("session_start", () => {
3018
+ registerOnce();
2889
3019
  });
2890
3020
  };
2891
3021
  }
2892
3022
  //#endregion
3023
+ //#region src/extensions/resource-pressure-warning.ts
3024
+ /**
3025
+ * Mid-run resource-pressure warning to the agent.
3026
+ *
3027
+ * The sandbox already detects pressure — the boot scripts cap the
3028
+ * user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
3029
+ * edges for memory and disk — but nothing told the *agent*, so a turn burned
3030
+ * straight to the OOM kill (or a full disk) and only learned about it from
3031
+ * the post-mortem notice. This extension closes that gap in-process: while a
3032
+ * turn is active it polls the agent cgroup and the root filesystem and, the
3033
+ * first time usage crosses a warn threshold, folds a system notification into
3034
+ * the open turn so the agent can checkpoint, shed work (constrain
3035
+ * parallelism, kill a background hog, clean scratch space), or request a
3036
+ * bigger tier BEFORE the kill.
3037
+ *
3038
+ * The notification is triggered by the two conditions that actually kill
3039
+ * work — memory near the cgroup hard cap, disk near full — and reports a
3040
+ * snapshot of all the relevant stats (memory, CPU utilization, disk) so the
3041
+ * agent can tell which resource is the problem and how much headroom the
3042
+ * others have.
3043
+ *
3044
+ * Edge-triggered, once per trigger per turn: the fired flags reset on
3045
+ * agent_start, so a turn that rides a threshold gets one warning per
3046
+ * resource, not a stream. Polling only runs while the agent is active — an
3047
+ * idle sandbox's resource usage is not the agent's problem and there is no
3048
+ * open turn to deliver into anyway.
3049
+ *
3050
+ * Best-effort throughout: any read failure (cgroup absent, controller not
3051
+ * delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
3052
+ * stat and the extension warns on what it can see — it must never break a
3053
+ * turn over an observability feature.
3054
+ */
3055
+ const execFileAsync = promisify(execFile);
3056
+ const log$2 = logger.child({ module: "resource-pressure-warning" });
3057
+ const POLL_INTERVAL_MS = 1e4;
3058
+ function envOverride(name) {
3059
+ for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
3060
+ const value = process.env[`${prefix}${name}`];
3061
+ if (value != null && value !== "") return value;
3062
+ }
3063
+ return null;
3064
+ }
3065
+ function cgroupDir() {
3066
+ return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
3067
+ }
3068
+ function diskRoot() {
3069
+ return envOverride("DISK_ROOT") ?? "/";
3070
+ }
3071
+ /**
3072
+ * Read a cgroup v2 scalar file. Returns a number, or null for "max"
3073
+ * (uncapped), an empty/absent file, or any read/parse error — an uncapped or
3074
+ * unreadable limit means there is nothing meaningful to warn against.
3075
+ */
3076
+ async function readScalar(file) {
3077
+ try {
3078
+ const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
3079
+ if (raw === "" || raw === "max") return null;
3080
+ const n = Number(raw);
3081
+ return Number.isFinite(n) ? n : null;
3082
+ } catch (_error) {
3083
+ return null;
3084
+ }
3085
+ }
3086
+ /**
3087
+ * Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
3088
+ * cpu.stat) and return the counter for `key`, or null when absent.
3089
+ */
3090
+ async function readKeyedCounter(file, key) {
3091
+ try {
3092
+ const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
3093
+ for (const line of raw.split("\n")) {
3094
+ const [k, v] = line.trim().split(/\s+/);
3095
+ if (k === key) {
3096
+ const n = Number(v);
3097
+ return Number.isFinite(n) ? n : null;
3098
+ }
3099
+ }
3100
+ return null;
3101
+ } catch (_error) {
3102
+ return null;
3103
+ }
3104
+ }
3105
+ /**
3106
+ * Live memory usage as an integer percent of the hard cap, or null when
3107
+ * either side is unreadable/uncapped. Exported for tests.
3108
+ */
3109
+ async function readMemUsePct() {
3110
+ const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
3111
+ if (current === null || max === null || max <= 0) return null;
3112
+ return {
3113
+ pct: Math.floor(current / max * 100),
3114
+ currentBytes: current,
3115
+ maxBytes: max
3116
+ };
3117
+ }
3118
+ /**
3119
+ * Root filesystem used% (df -P Capacity column), or null on any failure.
3120
+ * Exported for tests.
3121
+ */
3122
+ async function readDiskUsePct() {
3123
+ try {
3124
+ const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
3125
+ const dataRow = stdout.trim().split("\n")[1];
3126
+ if (dataRow == null) return null;
3127
+ const capacity = dataRow.trim().split(/\s+/)[4];
3128
+ if (capacity == null) return null;
3129
+ const pct = Number(capacity.replace("%", ""));
3130
+ return Number.isFinite(pct) ? pct : null;
3131
+ } catch (_error) {
3132
+ return null;
3133
+ }
3134
+ }
3135
+ /**
3136
+ * CPU utilization sampler. cgroup v2 exposes cumulative CPU time
3137
+ * (cpu.stat usage_usec); utilization is the delta between two samples over
3138
+ * the wall time between them, normalized by core count. The first call after
3139
+ * construction has no previous sample and returns null.
3140
+ */
3141
+ function createCpuSampler() {
3142
+ let prevUsageUsec = null;
3143
+ let prevAtMs = null;
3144
+ return async () => {
3145
+ const usage = await readKeyedCounter("cpu.stat", "usage_usec");
3146
+ const now = Date.now();
3147
+ const prev = prevUsageUsec;
3148
+ const prevAt = prevAtMs;
3149
+ prevUsageUsec = usage;
3150
+ prevAtMs = now;
3151
+ if (usage === null || prev === null || prevAt === null) return null;
3152
+ const wallUsec = (now - prevAt) * 1e3;
3153
+ if (wallUsec <= 0) return null;
3154
+ const cores = availableParallelism();
3155
+ const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
3156
+ return Math.max(0, Math.min(100, pct));
3157
+ };
3158
+ }
3159
+ function fmtMb(bytes) {
3160
+ return Math.round(bytes / 1024 / 1024);
3161
+ }
3162
+ /** The model-facing warning text. Exported for tests. */
3163
+ function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
3164
+ const stats = [];
3165
+ if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
3166
+ if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
3167
+ if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
3168
+ 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.`;
3169
+ 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.";
3170
+ 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>`;
3171
+ }
3172
+ const resourcePressureWarningExtension = (pi) => {
3173
+ let agentActive = false;
3174
+ let warnedMemThisTurn = false;
3175
+ let warnedDiskThisTurn = false;
3176
+ let timer = null;
3177
+ const sampleCpu = createCpuSampler();
3178
+ async function checkOnce() {
3179
+ if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
3180
+ const [mem, cpuPct, diskPct] = await Promise.all([
3181
+ readMemUsePct(),
3182
+ sampleCpu(),
3183
+ readDiskUsePct()
3184
+ ]);
3185
+ let trigger = null;
3186
+ if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
3187
+ trigger = "memory";
3188
+ warnedMemThisTurn = true;
3189
+ } else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
3190
+ trigger = "disk";
3191
+ warnedDiskThisTurn = true;
3192
+ }
3193
+ if (trigger === null) return;
3194
+ log$2.warn({
3195
+ trigger,
3196
+ mem,
3197
+ cpuPct,
3198
+ diskPct
3199
+ }, "resource pressure warning delivered to agent");
3200
+ await pi.sendMessage({
3201
+ customType: "anyone-resource-pressure-warning",
3202
+ content: resourcePressureWarningText(trigger, {
3203
+ mem,
3204
+ cpuPct,
3205
+ diskPct
3206
+ }),
3207
+ display: false
3208
+ }, {
3209
+ triggerTurn: true,
3210
+ deliverAs: "followUp"
3211
+ });
3212
+ }
3213
+ pi.on("agent_start", async () => {
3214
+ agentActive = true;
3215
+ warnedMemThisTurn = false;
3216
+ warnedDiskThisTurn = false;
3217
+ if (!timer) {
3218
+ timer = setInterval(() => {
3219
+ checkOnce().catch((err) => {
3220
+ log$2.error({ err }, "resource pressure check failed");
3221
+ });
3222
+ }, POLL_INTERVAL_MS);
3223
+ timer.unref?.();
3224
+ }
3225
+ });
3226
+ pi.on("agent_end", async () => {
3227
+ agentActive = false;
3228
+ if (timer) {
3229
+ clearInterval(timer);
3230
+ timer = null;
3231
+ }
3232
+ });
3233
+ };
3234
+ //#endregion
2893
3235
  //#region src/extensions/tool-call-env.ts
2894
3236
  const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
2895
3237
  function shellQuoteValue(value) {
@@ -3663,7 +4005,8 @@ function platformExtensions({ sessionId, channelContext }) {
3663
4005
  selfTraceExtension,
3664
4006
  createBackgroundTasksExtension({ channelContext }),
3665
4007
  createSubagentExtension({ channelContext }),
3666
- createContextManagementExtension()
4008
+ createContextManagementExtension(),
4009
+ resourcePressureWarningExtension
3667
4010
  ];
3668
4011
  }
3669
4012
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.113",
3
+ "version": "0.1.0-beta.1132",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",
@@ -17,12 +17,6 @@
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": {