@skydiveai/pi-extensions 0.1.0-beta.88 → 0.1.0-beta.882

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 +379 -110
  2. package/package.json +2 -7
package/dist/index.mjs CHANGED
@@ -21,6 +21,10 @@ 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";
27
+ import { quote } from "shell-quote";
24
28
  import { createWriteStream } from "node:fs";
25
29
  import { finished } from "node:stream/promises";
26
30
  import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
@@ -255,7 +259,7 @@ function createHealthHandler({ metadata }) {
255
259
  * read on the hot path before every LLM call), it falls back to the default
256
260
  * for that knob and logs once.
257
261
  */
258
- const log$13 = logger.child({ module: "context-management-config" });
262
+ const log$14 = logger.child({ module: "context-management-config" });
259
263
  const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
260
264
  enabled: false,
261
265
  perResultMaxBytes: 16 * 1024,
@@ -303,7 +307,7 @@ function resolveContextManagementConfig(env = process.env) {
303
307
  maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
304
308
  });
305
309
  if (!parsed.success) {
306
- log$13.warn({
310
+ log$14.warn({
307
311
  event: "context_management_config_invalid",
308
312
  err: parsed.error
309
313
  }, "falling back to default context-management config");
@@ -437,7 +441,7 @@ const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task i
437
441
  * or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
438
442
  * `tools/_example.ts` documents the shape without registering.
439
443
  */
440
- const log$12 = logger.child({ module: "local-tools-extension" });
444
+ const log$13 = logger.child({ module: "local-tools-extension" });
441
445
  const TOOLS_DIRNAME = "tools";
442
446
  const fileState = /* @__PURE__ */ new Map();
443
447
  let pendingLocalToolsUpdate = null;
@@ -581,7 +585,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
581
585
  dir
582
586
  });
583
587
  if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
584
- log$12.info({
588
+ log$13.info({
585
589
  event: "local_tools_reconcile",
586
590
  reason,
587
591
  total_tools: summary.totalTools,
@@ -603,7 +607,7 @@ const localToolsExtension = (pi) => {
603
607
  reason: "session_start"
604
608
  });
605
609
  } catch (err) {
606
- log$12.error({
610
+ log$13.error({
607
611
  err,
608
612
  event: "local_tools_reconcile_failed"
609
613
  }, "local tools reconcile failed");
@@ -615,7 +619,7 @@ const localToolsExtension = (pi) => {
615
619
  try {
616
620
  current = await listToolFiles(dir);
617
621
  } catch (err) {
618
- log$12.warn({
622
+ log$13.warn({
619
623
  err,
620
624
  event: "local_tools_listing_failed"
621
625
  }, "tools/ listing failed");
@@ -637,7 +641,7 @@ const localToolsExtension = (pi) => {
637
641
  reason: "auto_reload"
638
642
  });
639
643
  } catch (err) {
640
- log$12.error({
644
+ log$13.error({
641
645
  err,
642
646
  event: "local_tools_auto_reload_failed"
643
647
  }, "auto-reload after tools/ change failed");
@@ -862,7 +866,7 @@ async function loadMcpConfig(path) {
862
866
  * Clients are keyed by JSON-stringified config and reused across
863
867
  * reloads — only changed configs reconnect.
864
868
  */
865
- const log$11 = logger.child({ module: "mcp-extension" });
869
+ const log$12 = logger.child({ module: "mcp-extension" });
866
870
  async function closeConnected(connected) {
867
871
  try {
868
872
  await connected.client.close();
@@ -1226,7 +1230,7 @@ var McpExtension = class {
1226
1230
  });
1227
1231
  this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1228
1232
  if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
1229
- log$11.info({
1233
+ log$12.info({
1230
1234
  event: "mcp_reconcile",
1231
1235
  reason,
1232
1236
  total_tools: summary.totalTools,
@@ -1249,7 +1253,7 @@ var McpExtension = class {
1249
1253
  reason: "session_start"
1250
1254
  });
1251
1255
  } catch (err) {
1252
- log$11.error({
1256
+ log$12.error({
1253
1257
  err,
1254
1258
  event: "mcp_reconcile_failed"
1255
1259
  }, "MCP reconcile failed");
@@ -1261,7 +1265,7 @@ var McpExtension = class {
1261
1265
  try {
1262
1266
  mtime = await readConfigMtimeMs(configPath);
1263
1267
  } catch (err) {
1264
- log$11.warn({
1268
+ log$12.warn({
1265
1269
  err,
1266
1270
  event: "mcp_mtime_check_failed"
1267
1271
  }, "mtime check on mcp.config.json failed");
@@ -1275,7 +1279,7 @@ var McpExtension = class {
1275
1279
  reason: "auto_reload"
1276
1280
  });
1277
1281
  } catch (err) {
1278
- log$11.error({
1282
+ log$12.error({
1279
1283
  err,
1280
1284
  event: "mcp_auto_reload_failed"
1281
1285
  }, "auto-reload after mcp.config.json change failed");
@@ -1679,20 +1683,77 @@ function apiBaseUrl() {
1679
1683
  }
1680
1684
  //#endregion
1681
1685
  //#region src/extensions/platform.ts
1686
+ /**
1687
+ * Platform extension — bridges the agent harness to the Skydive platform daemon.
1688
+ *
1689
+ * Responsibilities:
1690
+ * - Heartbeat: periodic POST to the API so the sandbox manager knows the
1691
+ * agent is alive. Throttled to once per minute, triggered by tool events.
1692
+ * - Session tracking: registers the session with the daemon on start,
1693
+ * streams tool_call / tool_result events so the daemon can track which
1694
+ * session is actively executing, and signals session end on agent_end.
1695
+ * - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
1696
+ * messageId) to the daemon so file writes can be attributed to the
1697
+ * correct conversation.
1698
+ *
1699
+ * All daemon POSTs are fire-and-forget — failures are logged but never
1700
+ * block the agent. The daemon may not be running (e.g. local dev without
1701
+ * a sandbox), and that's fine.
1702
+ */
1682
1703
  const HEARTBEAT_THROTTLE_MS = 6e4;
1683
1704
  const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
1684
1705
  const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
1685
1706
  const DAEMON_URL = "http://localhost:38994";
1686
- const log$10 = logger.child({ module: "platform-ext" });
1707
+ const log$11 = logger.child({ module: "platform-ext" });
1687
1708
  function sandboxClient() {
1688
1709
  const apiUrl = apiBaseUrl();
1689
1710
  if (!apiUrl) return null;
1690
1711
  return hc(`${apiUrl}/api/v1/sandbox`);
1691
1712
  }
1692
1713
  /**
1693
- * Fetch every harness feature flag in one GET (`{ contextManagement, subagent,
1694
- * ... }` see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1695
- * null when indeterminate (no api url, or the request failed) so the shared
1714
+ * Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
1715
+ * feature-flags half of the ANY-5184 pool 403 wave.)
1716
+ *
1717
+ * `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
1718
+ * from a pool box can only 403 — a guaranteed-failing GET every 60s for the
1719
+ * life of the pool phase. The discriminator is the sandbox token's `type`
1720
+ * claim, read UNVERIFIED (this box never holds the signing secret): not an
1721
+ * authorization decision, only "should I bother calling?", and the api still
1722
+ * authorizes every request.
1723
+ *
1724
+ * Read per call from the daemon's persisted env file, NOT process.env:
1725
+ * claiming a pool box rebinds the token in place (the daemon rewrites this
1726
+ * file) while the harness's process.env keeps the boot snapshot, so a
1727
+ * process-env gate would leave a claimed box permanently skipping — trading a
1728
+ * wasted request for silently frozen flags, which is strictly worse. "Cannot
1729
+ * tell" (no file, no token, unparseable payload) reports false so the poll
1730
+ * proceeds.
1731
+ */
1732
+ const daemonEnvIdentitySchema = z.object({
1733
+ ANYONE_SANDBOX_TOKEN: z.string().optional(),
1734
+ SKYDIVE_SANDBOX_TOKEN: z.string().optional()
1735
+ }).passthrough();
1736
+ const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
1737
+ async function isPoolIdentity() {
1738
+ try {
1739
+ const envFile = process.env.ANYONE_DAEMON_ENV_CACHE ?? "/tmp/.anyone/daemon-env.json";
1740
+ const env = daemonEnvIdentitySchema.safeParse(JSON.parse(await readFile(envFile, "utf8")));
1741
+ if (!env.success) return false;
1742
+ const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
1743
+ if (typeof token !== "string" || token === "") return false;
1744
+ const payload = token.split(".")[1];
1745
+ if (!payload) return false;
1746
+ const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
1747
+ return claims.success && claims.data.type === "onboarding-pool";
1748
+ } catch (_err) {
1749
+ return false;
1750
+ }
1751
+ }
1752
+ /**
1753
+ * Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
1754
+ * — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1755
+ * null when indeterminate (no api url, the request failed, or the box is an
1756
+ * unclaimed pool sandbox whose token the route would 403) so the shared
1696
1757
  * poller keeps the last-known values rather than flipping on a transient error.
1697
1758
  * This is the single fetch behind `feature-flags-poll.ts`; extensions read the
1698
1759
  * polled values there instead of issuing their own GET.
@@ -1700,22 +1761,19 @@ function sandboxClient() {
1700
1761
  async function fetchHarnessFlags() {
1701
1762
  const client = sandboxClient();
1702
1763
  if (!client) return null;
1764
+ if (await isPoolIdentity()) return null;
1703
1765
  try {
1704
1766
  const res = await client["feature-flags"].$get();
1705
1767
  if (!res.ok) {
1706
- log$10.debug({
1768
+ log$11.debug({
1707
1769
  status: res.status,
1708
1770
  event: "feature_flags_fetch_failed"
1709
1771
  }, "feature-flags fetch failed");
1710
1772
  return null;
1711
1773
  }
1712
- const body = await res.json();
1713
- return {
1714
- contextManagement: body.contextManagement ?? null,
1715
- subagent: body.subagent ?? null
1716
- };
1774
+ return { contextManagement: (await res.json()).contextManagement ?? null };
1717
1775
  } catch (err) {
1718
- log$10.debug({
1776
+ log$11.debug({
1719
1777
  err,
1720
1778
  event: "feature_flags_fetch_error"
1721
1779
  }, "feature-flags request errored");
@@ -1726,7 +1784,7 @@ function postHeartbeat({ messageId }) {
1726
1784
  const client = sandboxClient();
1727
1785
  if (!client) return;
1728
1786
  client.heartbeat.$post({ json: { messageId } }).catch((err) => {
1729
- log$10.debug({
1787
+ log$11.debug({
1730
1788
  err,
1731
1789
  event: "heartbeat_failed"
1732
1790
  }, "heartbeat failed");
@@ -1738,7 +1796,7 @@ async function resolveConversationFromApi(messageId) {
1738
1796
  try {
1739
1797
  const res = await client["message-conversation"].$get({ query: { messageId } });
1740
1798
  if (!res.ok) {
1741
- log$10.warn({
1799
+ log$11.warn({
1742
1800
  status: res.status,
1743
1801
  messageId,
1744
1802
  event: "resolve_conversation_failed"
@@ -1747,7 +1805,7 @@ async function resolveConversationFromApi(messageId) {
1747
1805
  }
1748
1806
  return (await res.json()).conversationId ?? null;
1749
1807
  } catch (err) {
1750
- log$10.warn({
1808
+ log$11.warn({
1751
1809
  err,
1752
1810
  messageId,
1753
1811
  event: "resolve_conversation_error"
@@ -1772,7 +1830,11 @@ async function postSubagentSpawn({ messageId, tasks }) {
1772
1830
  tasks
1773
1831
  } });
1774
1832
  if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
1775
- return { taskIds: (await res.json()).taskIds };
1833
+ const body = await res.json();
1834
+ return {
1835
+ taskIds: body.taskIds,
1836
+ tasks: body.tasks ?? []
1837
+ };
1776
1838
  }
1777
1839
  function createHeartbeatThrottle({ messageId }) {
1778
1840
  let lastAt = 0;
@@ -1821,7 +1883,7 @@ function createToolHeartbeat({ messageId }) {
1821
1883
  }
1822
1884
  heartbeatCount++;
1823
1885
  if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
1824
- log$10.warn({
1886
+ log$11.warn({
1825
1887
  heartbeatCount,
1826
1888
  activeToolCalls: [...activeToolCalls]
1827
1889
  }, "tool heartbeat max reached, stopping");
@@ -1852,7 +1914,7 @@ function postToDaemon(path, body) {
1852
1914
  headers: { "content-type": "application/json" },
1853
1915
  body: JSON.stringify(body)
1854
1916
  }).catch((err) => {
1855
- log$10.debug({
1917
+ log$11.debug({
1856
1918
  err,
1857
1919
  path,
1858
1920
  event: "daemon_post_failed"
@@ -1861,7 +1923,7 @@ function postToDaemon(path, body) {
1861
1923
  }
1862
1924
  function createPlatformExtensions({ sessionId, channelContext }) {
1863
1925
  return (pi) => {
1864
- log$10.info({
1926
+ log$11.info({
1865
1927
  sessionId,
1866
1928
  hasChannelContext: Boolean(channelContext)
1867
1929
  }, "platform extension initialized");
@@ -1910,7 +1972,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1910
1972
  });
1911
1973
  });
1912
1974
  pi.on("agent_end", () => {
1913
- log$10.info({ sessionId }, "session ending");
1975
+ log$11.info({ sessionId }, "session ending");
1914
1976
  postToDaemon("/session/end", { sessionId });
1915
1977
  });
1916
1978
  };
@@ -1921,37 +1983,29 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1921
1983
  * Shared harness feature-flag poll.
1922
1984
  *
1923
1985
  * The api exposes one `/feature-flags` GET that returns every harness flag in a
1924
- * single response (`{ contextManagement, subagent, commandFlags }` — see
1986
+ * single response (`{ contextManagement, commandFlags }` — see
1925
1987
  * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1926
1988
  * extension issuing its own GET — and, worse, a *blocking* GET on the
1927
1989
  * pre-first-token `session_start` path — a single background poller fetches
1928
1990
  * that response once per interval and fans the values out to every subscriber.
1929
1991
  *
1930
- * Why one poller: the subagent extension gates its tool registration on the
1931
- * `subagent` flag. If it awaited a fresh GET inside `session_start` the tool
1932
- * schema (part of the prefill) couldn't be finalized until a serial
1933
- * sandbox→api round-trip settled, adding a net-new pre-token network hop on
1934
- * every session, flag on or off. Reading the last-polled value instead keeps
1935
- * the hot path allocation-only. A cold cache reads as `null` (fail-open to
1936
- * unregistered); a newly-flipped flag takes effect on the next poll, matching
1937
- * how context-management already treats its flag.
1992
+ * Why one poller: context-management consumes the `contextManagement` flag
1993
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
1994
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
1995
+ * as `null` and a newly-flipped flag takes effect on the next poll.
1938
1996
  *
1939
1997
  * The poll is fire-and-forget and self-unref'd — it never keeps the process
1940
1998
  * alive and an indeterminate result (no api url / transient failure) leaves the
1941
1999
  * last-known values untouched so a blip can't silently flip behavior.
1942
2000
  */
1943
- const log$9 = logger.child({ module: "feature-flags-poll" });
2001
+ const log$10 = logger.child({ module: "feature-flags-poll" });
1944
2002
  const FLAG_POLL_INTERVAL_MS = 6e4;
1945
2003
  let contextManagement = null;
1946
- let subagent = null;
1947
- const subscribers = {
1948
- contextManagement: /* @__PURE__ */ new Set(),
1949
- subagent: /* @__PURE__ */ new Set()
1950
- };
2004
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
1951
2005
  let pollerStarted = false;
1952
2006
  let firstPollSettled = false;
1953
2007
  let resolveFirstPoll = null;
1954
- const firstPollPromise = new Promise((resolve) => {
2008
+ new Promise((resolve) => {
1955
2009
  resolveFirstPoll = resolve;
1956
2010
  });
1957
2011
  function markFirstPollSettled() {
@@ -1960,20 +2014,8 @@ function markFirstPollSettled() {
1960
2014
  resolveFirstPoll?.();
1961
2015
  }
1962
2016
  /** Last-polled value of a flag, or `null` if not yet resolved. */
1963
- function getPolledFlag(name) {
1964
- return name === "contextManagement" ? contextManagement : subagent;
1965
- }
1966
- /**
1967
- * Await the first poll already kicked by `startFeatureFlagPoller` (never a new
1968
- * GET). Resolves when that poll settles, immediately if it already has, or
1969
- * immediately when there's no flag source to poll. Callers on the hot path
1970
- * should race this against their own short timeout so a slow/failed flag
1971
- * service cannot delay first-token; a timeout just means the caller reads the
1972
- * still-cold cache and falls back to its default, exactly as before.
1973
- */
1974
- function awaitFirstFlagPoll() {
1975
- if (firstPollSettled || !hasFlagSource()) return Promise.resolve();
1976
- return firstPollPromise;
2017
+ function getPolledFlag(_name) {
2018
+ return contextManagement;
1977
2019
  }
1978
2020
  /**
1979
2021
  * Subscribe to changes of a flag. The callback fires only on a *transition*
@@ -1986,13 +2028,12 @@ function onFlagChange(name, cb) {
1986
2028
  }
1987
2029
  function apply(name, next) {
1988
2030
  if (next === null) return;
1989
- const prev = name === "contextManagement" ? contextManagement : subagent;
1990
- if (name === "contextManagement") contextManagement = next;
1991
- else subagent = next;
2031
+ const prev = contextManagement;
2032
+ contextManagement = next;
1992
2033
  if (next !== prev) for (const cb of subscribers[name]) try {
1993
2034
  cb(next);
1994
2035
  } catch (err) {
1995
- log$9.warn({
2036
+ log$10.warn({
1996
2037
  err,
1997
2038
  flag: name
1998
2039
  }, "flag subscriber threw");
@@ -2003,9 +2044,8 @@ async function pollOnce() {
2003
2044
  const flags = await fetchHarnessFlags();
2004
2045
  if (!flags) return;
2005
2046
  apply("contextManagement", flags.contextManagement ?? null);
2006
- apply("subagent", flags.subagent ?? null);
2007
2047
  } catch (err) {
2008
- log$9.debug({ err }, "feature-flag poll threw");
2048
+ log$10.debug({ err }, "feature-flag poll threw");
2009
2049
  }
2010
2050
  }
2011
2051
  /**
@@ -2112,7 +2152,7 @@ function transformContextMessages(messages, config, now) {
2112
2152
  }
2113
2153
  //#endregion
2114
2154
  //#region src/extensions/context-management.ts
2115
- const log$8 = logger.child({ module: "context-management-extension" });
2155
+ const log$9 = logger.child({ module: "context-management-extension" });
2116
2156
  function isAnthropicMessagesPayload(payload) {
2117
2157
  if (typeof payload !== "object" || payload === null) return false;
2118
2158
  const candidate = payload;
@@ -2177,13 +2217,13 @@ function createContextManagementExtension() {
2177
2217
  setContextManagementFlagOverride(getPolledFlag("contextManagement"));
2178
2218
  onFlagChange("contextManagement", (enabled) => {
2179
2219
  setContextManagementFlagOverride(enabled);
2180
- log$8.info({
2220
+ log$9.info({
2181
2221
  event: "context_management_flag_update",
2182
2222
  enabled
2183
2223
  }, "context-management flag updated from platform");
2184
2224
  });
2185
2225
  startFeatureFlagPoller();
2186
- log$8.info({
2226
+ log$9.info({
2187
2227
  event: "context_management_registered",
2188
2228
  enabled: initial.enabled,
2189
2229
  flagSource: hasFlagSource(),
@@ -2195,13 +2235,13 @@ function createContextManagementExtension() {
2195
2235
  const { messages } = event;
2196
2236
  try {
2197
2237
  const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
2198
- if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$8.info({
2238
+ if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$9.info({
2199
2239
  event: "context_management_applied",
2200
2240
  ...result.stats
2201
2241
  }, "trimmed/cleared tool output before LLM call");
2202
2242
  return { messages: result.messages };
2203
2243
  } catch (err) {
2204
- log$8.error({
2244
+ log$9.error({
2205
2245
  err,
2206
2246
  event: "context_management_transform_failed"
2207
2247
  }, "context transform failed; passing messages through unchanged");
@@ -2213,7 +2253,7 @@ function createContextManagementExtension() {
2213
2253
  }
2214
2254
  //#endregion
2215
2255
  //#region src/extensions/current-time.ts
2216
- const log$7 = logger.child({ module: "current-time-extension" });
2256
+ const log$8 = logger.child({ module: "current-time-extension" });
2217
2257
  const PI_DATE_LINE = /^Current date:.*$/m;
2218
2258
  function formatCurrentTimeLine(now) {
2219
2259
  return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
@@ -2226,7 +2266,7 @@ const currentTimeExtension = (pi) => {
2226
2266
  const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
2227
2267
  const base = event.systemPrompt;
2228
2268
  if (PI_DATE_LINE.test(base)) {
2229
- 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)");
2269
+ 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)");
2230
2270
  return { systemPrompt: base.replace(PI_DATE_LINE, line) };
2231
2271
  }
2232
2272
  return { systemPrompt: `${base}\n${line}` };
@@ -2463,7 +2503,7 @@ function renderIndex(entries) {
2463
2503
  }
2464
2504
  //#endregion
2465
2505
  //#region src/extensions/memory.ts
2466
- const log$6 = logger.child({ module: "memory-extension" });
2506
+ const log$7 = logger.child({ module: "memory-extension" });
2467
2507
  /**
2468
2508
  * The standing instructions for the memory system. Always injected (even with
2469
2509
  * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
@@ -2497,7 +2537,7 @@ const memoryExtension = (pi) => {
2497
2537
  index
2498
2538
  });
2499
2539
  } catch (err) {
2500
- log$6.warn({
2540
+ log$7.warn({
2501
2541
  err,
2502
2542
  event: "memory_index_failed"
2503
2543
  }, "memory index build failed; injecting instructions only");
@@ -2511,7 +2551,7 @@ const memoryExtension = (pi) => {
2511
2551
  };
2512
2552
  //#endregion
2513
2553
  //#region src/extensions/platform-memory.ts
2514
- const log$5 = logger.child({ module: "platform-memory-extension" });
2554
+ const log$6 = logger.child({ module: "platform-memory-extension" });
2515
2555
  /**
2516
2556
  * Resolve the human on this turn via the API, keyed by the message id.
2517
2557
  * `/sandbox/channel-context` only returns a sender for a platform-known
@@ -2524,13 +2564,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
2524
2564
  async function resolveTurnUser(messageId) {
2525
2565
  const client = sandboxClient();
2526
2566
  if (!client) {
2527
- log$5.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2567
+ log$6.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2528
2568
  return null;
2529
2569
  }
2530
2570
  try {
2531
2571
  const res = await client["channel-context"].$get({ query: { messageId } });
2532
2572
  if (!res.ok) {
2533
- log$5.warn({
2573
+ log$6.warn({
2534
2574
  event: "resolve_turn_user_failed",
2535
2575
  status: res.status
2536
2576
  }, "channel-context returned non-ok; withholding user memory");
@@ -2543,7 +2583,7 @@ async function resolveTurnUser(messageId) {
2543
2583
  displayName: sender.displayName
2544
2584
  };
2545
2585
  } catch (err) {
2546
- log$5.warn({
2586
+ log$6.warn({
2547
2587
  err,
2548
2588
  event: "resolve_turn_user_failed"
2549
2589
  }, "failed to resolve current user; withholding user memory");
@@ -2590,7 +2630,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2590
2630
  user
2591
2631
  });
2592
2632
  } catch (err) {
2593
- log$5.warn({
2633
+ log$6.warn({
2594
2634
  err,
2595
2635
  event: "user_memory_index_failed"
2596
2636
  }, "user memory index build failed; skipping injection");
@@ -2605,7 +2645,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2605
2645
  }
2606
2646
  //#endregion
2607
2647
  //#region src/extensions/self-trace.ts
2608
- const log$4 = logger.child({ module: "self-trace-extension" });
2648
+ const log$5 = logger.child({ module: "self-trace-extension" });
2609
2649
  /**
2610
2650
  * Reports the agent's own execution as OpenTelemetry spans:
2611
2651
  * agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
@@ -2630,7 +2670,7 @@ const selfTraceExtension = (pi) => {
2630
2670
  sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
2631
2671
  sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
2632
2672
  const sc = sessionSpan.spanContext();
2633
- log$4.info({
2673
+ log$5.info({
2634
2674
  event: "self_trace_session_start",
2635
2675
  trace_id: sc.traceId,
2636
2676
  span_id: sc.spanId,
@@ -2742,13 +2782,13 @@ const selfTraceExtension = (pi) => {
2742
2782
  * Lives in the harness package — soul.md is content from the agent's
2743
2783
  * own git repo, not from the platform — so its handling stays here.
2744
2784
  */
2745
- const log$3 = logger.child({ module: "soul-extension" });
2785
+ const log$4 = logger.child({ module: "soul-extension" });
2746
2786
  async function readSoul(cwd) {
2747
2787
  try {
2748
2788
  return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
2749
2789
  } catch (err) {
2750
2790
  if (err?.code === "ENOENT") return null;
2751
- log$3.warn({
2791
+ log$4.warn({
2752
2792
  err,
2753
2793
  event: "soul_read_failed"
2754
2794
  }, "soul.md read failed");
@@ -2762,7 +2802,7 @@ function soulSection(cwd, soul) {
2762
2802
 
2763
2803
  **\`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.
2764
2804
 
2765
- 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.
2805
+ 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.
2766
2806
 
2767
2807
  ${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
2768
2808
  }
@@ -2780,8 +2820,7 @@ const soulExtension = (pi) => {
2780
2820
  };
2781
2821
  //#endregion
2782
2822
  //#region src/extensions/subagent/index.ts
2783
- const log$2 = logger.child({ module: "subagent-ext" });
2784
- const COLD_START_FLAG_WAIT_MS = 750;
2823
+ const log$3 = logger.child({ module: "subagent-ext" });
2785
2824
  const MAX_TASKS = 8;
2786
2825
  const TaskItem = Type.Object({
2787
2826
  task: Type.String({ description: "The task to delegate to a subagent run." }),
@@ -2790,8 +2829,14 @@ const TaskItem = Type.Object({
2790
2829
  maxLength: 120
2791
2830
  }),
2792
2831
  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." })),
2793
- 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." }))
2832
+ 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." })),
2833
+ timeoutMinutes: Type.Optional(Type.Integer({
2834
+ 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.",
2835
+ minimum: 1,
2836
+ maximum: 360
2837
+ }))
2794
2838
  });
2839
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2795
2840
  const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2796
2841
  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.",
2797
2842
  minItems: 1,
@@ -2806,7 +2851,9 @@ function buildTool(messageId) {
2806
2851
  "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2807
2852
  "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.",
2808
2853
  "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.",
2809
- "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."
2854
+ "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.",
2855
+ "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.",
2856
+ "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."
2810
2857
  ].join(" "),
2811
2858
  promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2812
2859
  parameters: SubagentParams,
@@ -2824,28 +2871,39 @@ function buildTool(messageId) {
2824
2871
  task: t.task,
2825
2872
  title: t.title ?? null,
2826
2873
  persona: t.persona ?? null,
2827
- model: t.model ?? null
2874
+ model: t.model ?? null,
2875
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2828
2876
  }));
2829
2877
  try {
2830
- const { taskIds } = await postSubagentSpawn({
2878
+ const spawned = await postSubagentSpawn({
2831
2879
  messageId,
2832
2880
  tasks: spawnTasks
2833
2881
  });
2834
- log$2.info({
2882
+ const { taskIds } = spawned;
2883
+ log$3.info({
2835
2884
  event: "subagent_spawned",
2836
2885
  count: taskIds.length
2837
2886
  }, "subagent tasks queued");
2838
- const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? ""}`).join("\n");
2887
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
2888
+ const lines = taskIds.map((id, i) => {
2889
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
2890
+ const conv = convByTask.get(id);
2891
+ return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
2892
+ }).join("\n");
2893
+ 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." : "";
2839
2894
  return {
2840
2895
  content: [{
2841
2896
  type: "text",
2842
- 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}`
2897
+ 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}`
2843
2898
  }],
2844
- details: { taskIds }
2899
+ details: {
2900
+ taskIds,
2901
+ tasks: spawned.tasks
2902
+ }
2845
2903
  };
2846
2904
  } catch (err) {
2847
2905
  const message = err instanceof Error ? err.message : String(err);
2848
- log$2.warn({
2906
+ log$3.warn({
2849
2907
  err,
2850
2908
  event: "subagent_spawn_failed"
2851
2909
  }, "subagent spawn failed");
@@ -2862,39 +2920,249 @@ function buildTool(messageId) {
2862
2920
  };
2863
2921
  }
2864
2922
  /**
2865
- * Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
2866
2923
  * The factory takes the session's channel context to resolve the originating
2867
2924
  * messageId — the api links each spawned run to the conversation that message
2868
2925
  * belongs to and rewakes it on completion (nothing about the parent is piped
2869
- * from the sandbox beyond that id).
2926
+ * from the sandbox beyond that id). The tool is registered unconditionally at
2927
+ * session_start.
2870
2928
  */
2871
2929
  function createSubagentExtension({ channelContext }) {
2872
2930
  return (pi) => {
2873
2931
  const messageId = extractMessageId(channelContext);
2874
- startFeatureFlagPoller();
2875
2932
  let registered = false;
2876
2933
  const registerOnce = () => {
2877
2934
  if (registered) return;
2878
2935
  registered = true;
2879
2936
  pi.registerTool(buildTool(messageId));
2880
- log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
2937
+ log$3.info({ event: "subagent_enabled" }, "subagent tool registered");
2881
2938
  };
2882
- onFlagChange("subagent", (enabled) => {
2883
- if (enabled) registerOnce();
2884
- });
2885
- pi.on("session_start", async () => {
2886
- if (getPolledFlag("subagent") === null) await Promise.race([awaitFirstFlagPoll(), new Promise((resolve) => setTimeout(resolve, COLD_START_FLAG_WAIT_MS).unref?.())]);
2887
- if (getPolledFlag("subagent") === true) registerOnce();
2939
+ pi.on("session_start", () => {
2940
+ registerOnce();
2888
2941
  });
2889
2942
  };
2890
2943
  }
2891
2944
  //#endregion
2945
+ //#region src/extensions/resource-pressure-warning.ts
2946
+ /**
2947
+ * Mid-run resource-pressure warning to the agent.
2948
+ *
2949
+ * The sandbox already detects pressure — the boot scripts cap the
2950
+ * user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
2951
+ * edges for memory and disk — but nothing told the *agent*, so a turn burned
2952
+ * straight to the OOM kill (or a full disk) and only learned about it from
2953
+ * the post-mortem notice. This extension closes that gap in-process: while a
2954
+ * turn is active it polls the agent cgroup and the root filesystem and, the
2955
+ * first time usage crosses a warn threshold, folds a system notification into
2956
+ * the open turn so the agent can checkpoint, shed work (constrain
2957
+ * parallelism, kill a background hog, clean scratch space), or request a
2958
+ * bigger tier BEFORE the kill.
2959
+ *
2960
+ * The notification is triggered by the two conditions that actually kill
2961
+ * work — memory near the cgroup hard cap, disk near full — and reports a
2962
+ * snapshot of all the relevant stats (memory, CPU utilization, disk) so the
2963
+ * agent can tell which resource is the problem and how much headroom the
2964
+ * others have.
2965
+ *
2966
+ * Edge-triggered, once per trigger per turn: the fired flags reset on
2967
+ * agent_start, so a turn that rides a threshold gets one warning per
2968
+ * resource, not a stream. Polling only runs while the agent is active — an
2969
+ * idle sandbox's resource usage is not the agent's problem and there is no
2970
+ * open turn to deliver into anyway.
2971
+ *
2972
+ * Best-effort throughout: any read failure (cgroup absent, controller not
2973
+ * delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
2974
+ * stat and the extension warns on what it can see — it must never break a
2975
+ * turn over an observability feature.
2976
+ */
2977
+ const execFileAsync = promisify(execFile);
2978
+ const log$2 = logger.child({ module: "resource-pressure-warning" });
2979
+ const POLL_INTERVAL_MS = 1e4;
2980
+ function envOverride(name) {
2981
+ for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
2982
+ const value = process.env[`${prefix}${name}`];
2983
+ if (value != null && value !== "") return value;
2984
+ }
2985
+ return null;
2986
+ }
2987
+ function cgroupDir() {
2988
+ return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
2989
+ }
2990
+ function diskRoot() {
2991
+ return envOverride("DISK_ROOT") ?? "/";
2992
+ }
2993
+ /**
2994
+ * Read a cgroup v2 scalar file. Returns a number, or null for "max"
2995
+ * (uncapped), an empty/absent file, or any read/parse error — an uncapped or
2996
+ * unreadable limit means there is nothing meaningful to warn against.
2997
+ */
2998
+ async function readScalar(file) {
2999
+ try {
3000
+ const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
3001
+ if (raw === "" || raw === "max") return null;
3002
+ const n = Number(raw);
3003
+ return Number.isFinite(n) ? n : null;
3004
+ } catch (_error) {
3005
+ return null;
3006
+ }
3007
+ }
3008
+ /**
3009
+ * Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
3010
+ * cpu.stat) and return the counter for `key`, or null when absent.
3011
+ */
3012
+ async function readKeyedCounter(file, key) {
3013
+ try {
3014
+ const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
3015
+ for (const line of raw.split("\n")) {
3016
+ const [k, v] = line.trim().split(/\s+/);
3017
+ if (k === key) {
3018
+ const n = Number(v);
3019
+ return Number.isFinite(n) ? n : null;
3020
+ }
3021
+ }
3022
+ return null;
3023
+ } catch (_error) {
3024
+ return null;
3025
+ }
3026
+ }
3027
+ /**
3028
+ * Live memory usage as an integer percent of the hard cap, or null when
3029
+ * either side is unreadable/uncapped. Exported for tests.
3030
+ */
3031
+ async function readMemUsePct() {
3032
+ const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
3033
+ if (current === null || max === null || max <= 0) return null;
3034
+ return {
3035
+ pct: Math.floor(current / max * 100),
3036
+ currentBytes: current,
3037
+ maxBytes: max
3038
+ };
3039
+ }
3040
+ /**
3041
+ * Root filesystem used% (df -P Capacity column), or null on any failure.
3042
+ * Exported for tests.
3043
+ */
3044
+ async function readDiskUsePct() {
3045
+ try {
3046
+ const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
3047
+ const dataRow = stdout.trim().split("\n")[1];
3048
+ if (dataRow == null) return null;
3049
+ const capacity = dataRow.trim().split(/\s+/)[4];
3050
+ if (capacity == null) return null;
3051
+ const pct = Number(capacity.replace("%", ""));
3052
+ return Number.isFinite(pct) ? pct : null;
3053
+ } catch (_error) {
3054
+ return null;
3055
+ }
3056
+ }
3057
+ /**
3058
+ * CPU utilization sampler. cgroup v2 exposes cumulative CPU time
3059
+ * (cpu.stat usage_usec); utilization is the delta between two samples over
3060
+ * the wall time between them, normalized by core count. The first call after
3061
+ * construction has no previous sample and returns null.
3062
+ */
3063
+ function createCpuSampler() {
3064
+ let prevUsageUsec = null;
3065
+ let prevAtMs = null;
3066
+ return async () => {
3067
+ const usage = await readKeyedCounter("cpu.stat", "usage_usec");
3068
+ const now = Date.now();
3069
+ const prev = prevUsageUsec;
3070
+ const prevAt = prevAtMs;
3071
+ prevUsageUsec = usage;
3072
+ prevAtMs = now;
3073
+ if (usage === null || prev === null || prevAt === null) return null;
3074
+ const wallUsec = (now - prevAt) * 1e3;
3075
+ if (wallUsec <= 0) return null;
3076
+ const cores = availableParallelism();
3077
+ const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
3078
+ return Math.max(0, Math.min(100, pct));
3079
+ };
3080
+ }
3081
+ function fmtMb(bytes) {
3082
+ return Math.round(bytes / 1024 / 1024);
3083
+ }
3084
+ /** The model-facing warning text. Exported for tests. */
3085
+ function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
3086
+ const stats = [];
3087
+ if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
3088
+ if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
3089
+ if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
3090
+ 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.`;
3091
+ 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.";
3092
+ 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>`;
3093
+ }
3094
+ const resourcePressureWarningExtension = (pi) => {
3095
+ let agentActive = false;
3096
+ let warnedMemThisTurn = false;
3097
+ let warnedDiskThisTurn = false;
3098
+ let timer = null;
3099
+ const sampleCpu = createCpuSampler();
3100
+ async function checkOnce() {
3101
+ if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
3102
+ const [mem, cpuPct, diskPct] = await Promise.all([
3103
+ readMemUsePct(),
3104
+ sampleCpu(),
3105
+ readDiskUsePct()
3106
+ ]);
3107
+ let trigger = null;
3108
+ if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
3109
+ trigger = "memory";
3110
+ warnedMemThisTurn = true;
3111
+ } else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
3112
+ trigger = "disk";
3113
+ warnedDiskThisTurn = true;
3114
+ }
3115
+ if (trigger === null) return;
3116
+ log$2.warn({
3117
+ trigger,
3118
+ mem,
3119
+ cpuPct,
3120
+ diskPct
3121
+ }, "resource pressure warning delivered to agent");
3122
+ await pi.sendMessage({
3123
+ customType: "anyone-resource-pressure-warning",
3124
+ content: resourcePressureWarningText(trigger, {
3125
+ mem,
3126
+ cpuPct,
3127
+ diskPct
3128
+ }),
3129
+ display: false
3130
+ }, {
3131
+ triggerTurn: true,
3132
+ deliverAs: "followUp"
3133
+ });
3134
+ }
3135
+ pi.on("agent_start", async () => {
3136
+ agentActive = true;
3137
+ warnedMemThisTurn = false;
3138
+ warnedDiskThisTurn = false;
3139
+ if (!timer) {
3140
+ timer = setInterval(() => {
3141
+ checkOnce().catch((err) => {
3142
+ log$2.error({ err }, "resource pressure check failed");
3143
+ });
3144
+ }, POLL_INTERVAL_MS);
3145
+ timer.unref?.();
3146
+ }
3147
+ });
3148
+ pi.on("agent_end", async () => {
3149
+ agentActive = false;
3150
+ if (timer) {
3151
+ clearInterval(timer);
3152
+ timer = null;
3153
+ }
3154
+ });
3155
+ };
3156
+ //#endregion
2892
3157
  //#region src/extensions/tool-call-env.ts
2893
3158
  const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
3159
+ function shellQuoteValue(value) {
3160
+ return quote([value]);
3161
+ }
2894
3162
  function withToolCallId({ command, toolCallId }) {
2895
- return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
3163
+ return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
2896
3164
  }
2897
- const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
3165
+ const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
2898
3166
  function stripPlatformExportsForDisplay(command) {
2899
3167
  let c = command;
2900
3168
  let m;
@@ -3659,7 +3927,8 @@ function platformExtensions({ sessionId, channelContext }) {
3659
3927
  selfTraceExtension,
3660
3928
  createBackgroundTasksExtension({ channelContext }),
3661
3929
  createSubagentExtension({ channelContext }),
3662
- createContextManagementExtension()
3930
+ createContextManagementExtension(),
3931
+ resourcePressureWarningExtension
3663
3932
  ];
3664
3933
  }
3665
3934
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.88",
3
+ "version": "0.1.0-beta.882",
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": {
@@ -45,6 +39,7 @@
45
39
  "@skydiveai/pi-server": "^0.1.0",
46
40
  "hono": "^4.6.14",
47
41
  "pino": "^9.6.0",
42
+ "shell-quote": "^1.8.4",
48
43
  "typebox": "^1.1.34",
49
44
  "yaml": "^2.8.3",
50
45
  "zod": "^3.25.0"