@skydiveai/pi-extensions 0.1.0-beta.751 → 0.1.0-beta.753

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 +264 -48
  2. package/package.json +1 -1
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");
@@ -863,7 +866,7 @@ async function loadMcpConfig(path) {
863
866
  * Clients are keyed by JSON-stringified config and reused across
864
867
  * reloads — only changed configs reconnect.
865
868
  */
866
- const log$11 = logger.child({ module: "mcp-extension" });
869
+ const log$12 = logger.child({ module: "mcp-extension" });
867
870
  async function closeConnected(connected) {
868
871
  try {
869
872
  await connected.client.close();
@@ -1227,7 +1230,7 @@ var McpExtension = class {
1227
1230
  });
1228
1231
  this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1229
1232
  if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
1230
- log$11.info({
1233
+ log$12.info({
1231
1234
  event: "mcp_reconcile",
1232
1235
  reason,
1233
1236
  total_tools: summary.totalTools,
@@ -1250,7 +1253,7 @@ var McpExtension = class {
1250
1253
  reason: "session_start"
1251
1254
  });
1252
1255
  } catch (err) {
1253
- log$11.error({
1256
+ log$12.error({
1254
1257
  err,
1255
1258
  event: "mcp_reconcile_failed"
1256
1259
  }, "MCP reconcile failed");
@@ -1262,7 +1265,7 @@ var McpExtension = class {
1262
1265
  try {
1263
1266
  mtime = await readConfigMtimeMs(configPath);
1264
1267
  } catch (err) {
1265
- log$11.warn({
1268
+ log$12.warn({
1266
1269
  err,
1267
1270
  event: "mcp_mtime_check_failed"
1268
1271
  }, "mtime check on mcp.config.json failed");
@@ -1276,7 +1279,7 @@ var McpExtension = class {
1276
1279
  reason: "auto_reload"
1277
1280
  });
1278
1281
  } catch (err) {
1279
- log$11.error({
1282
+ log$12.error({
1280
1283
  err,
1281
1284
  event: "mcp_auto_reload_failed"
1282
1285
  }, "auto-reload after mcp.config.json change failed");
@@ -1684,7 +1687,7 @@ const HEARTBEAT_THROTTLE_MS = 6e4;
1684
1687
  const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
1685
1688
  const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
1686
1689
  const DAEMON_URL = "http://localhost:38994";
1687
- const log$10 = logger.child({ module: "platform-ext" });
1690
+ const log$11 = logger.child({ module: "platform-ext" });
1688
1691
  function sandboxClient() {
1689
1692
  const apiUrl = apiBaseUrl();
1690
1693
  if (!apiUrl) return null;
@@ -1704,7 +1707,7 @@ async function fetchHarnessFlags() {
1704
1707
  try {
1705
1708
  const res = await client["feature-flags"].$get();
1706
1709
  if (!res.ok) {
1707
- log$10.debug({
1710
+ log$11.debug({
1708
1711
  status: res.status,
1709
1712
  event: "feature_flags_fetch_failed"
1710
1713
  }, "feature-flags fetch failed");
@@ -1712,7 +1715,7 @@ async function fetchHarnessFlags() {
1712
1715
  }
1713
1716
  return { contextManagement: (await res.json()).contextManagement ?? null };
1714
1717
  } catch (err) {
1715
- log$10.debug({
1718
+ log$11.debug({
1716
1719
  err,
1717
1720
  event: "feature_flags_fetch_error"
1718
1721
  }, "feature-flags request errored");
@@ -1723,7 +1726,7 @@ function postHeartbeat({ messageId }) {
1723
1726
  const client = sandboxClient();
1724
1727
  if (!client) return;
1725
1728
  client.heartbeat.$post({ json: { messageId } }).catch((err) => {
1726
- log$10.debug({
1729
+ log$11.debug({
1727
1730
  err,
1728
1731
  event: "heartbeat_failed"
1729
1732
  }, "heartbeat failed");
@@ -1735,7 +1738,7 @@ async function resolveConversationFromApi(messageId) {
1735
1738
  try {
1736
1739
  const res = await client["message-conversation"].$get({ query: { messageId } });
1737
1740
  if (!res.ok) {
1738
- log$10.warn({
1741
+ log$11.warn({
1739
1742
  status: res.status,
1740
1743
  messageId,
1741
1744
  event: "resolve_conversation_failed"
@@ -1744,7 +1747,7 @@ async function resolveConversationFromApi(messageId) {
1744
1747
  }
1745
1748
  return (await res.json()).conversationId ?? null;
1746
1749
  } catch (err) {
1747
- log$10.warn({
1750
+ log$11.warn({
1748
1751
  err,
1749
1752
  messageId,
1750
1753
  event: "resolve_conversation_error"
@@ -1822,7 +1825,7 @@ function createToolHeartbeat({ messageId }) {
1822
1825
  }
1823
1826
  heartbeatCount++;
1824
1827
  if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
1825
- log$10.warn({
1828
+ log$11.warn({
1826
1829
  heartbeatCount,
1827
1830
  activeToolCalls: [...activeToolCalls]
1828
1831
  }, "tool heartbeat max reached, stopping");
@@ -1853,7 +1856,7 @@ function postToDaemon(path, body) {
1853
1856
  headers: { "content-type": "application/json" },
1854
1857
  body: JSON.stringify(body)
1855
1858
  }).catch((err) => {
1856
- log$10.debug({
1859
+ log$11.debug({
1857
1860
  err,
1858
1861
  path,
1859
1862
  event: "daemon_post_failed"
@@ -1862,7 +1865,7 @@ function postToDaemon(path, body) {
1862
1865
  }
1863
1866
  function createPlatformExtensions({ sessionId, channelContext }) {
1864
1867
  return (pi) => {
1865
- log$10.info({
1868
+ log$11.info({
1866
1869
  sessionId,
1867
1870
  hasChannelContext: Boolean(channelContext)
1868
1871
  }, "platform extension initialized");
@@ -1911,7 +1914,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1911
1914
  });
1912
1915
  });
1913
1916
  pi.on("agent_end", () => {
1914
- log$10.info({ sessionId }, "session ending");
1917
+ log$11.info({ sessionId }, "session ending");
1915
1918
  postToDaemon("/session/end", { sessionId });
1916
1919
  });
1917
1920
  };
@@ -1937,7 +1940,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1937
1940
  * alive and an indeterminate result (no api url / transient failure) leaves the
1938
1941
  * last-known values untouched so a blip can't silently flip behavior.
1939
1942
  */
1940
- const log$9 = logger.child({ module: "feature-flags-poll" });
1943
+ const log$10 = logger.child({ module: "feature-flags-poll" });
1941
1944
  const FLAG_POLL_INTERVAL_MS = 6e4;
1942
1945
  let contextManagement = null;
1943
1946
  const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
@@ -1972,7 +1975,7 @@ function apply(name, next) {
1972
1975
  if (next !== prev) for (const cb of subscribers[name]) try {
1973
1976
  cb(next);
1974
1977
  } catch (err) {
1975
- log$9.warn({
1978
+ log$10.warn({
1976
1979
  err,
1977
1980
  flag: name
1978
1981
  }, "flag subscriber threw");
@@ -1984,7 +1987,7 @@ async function pollOnce() {
1984
1987
  if (!flags) return;
1985
1988
  apply("contextManagement", flags.contextManagement ?? null);
1986
1989
  } catch (err) {
1987
- log$9.debug({ err }, "feature-flag poll threw");
1990
+ log$10.debug({ err }, "feature-flag poll threw");
1988
1991
  }
1989
1992
  }
1990
1993
  /**
@@ -2091,7 +2094,7 @@ function transformContextMessages(messages, config, now) {
2091
2094
  }
2092
2095
  //#endregion
2093
2096
  //#region src/extensions/context-management.ts
2094
- const log$8 = logger.child({ module: "context-management-extension" });
2097
+ const log$9 = logger.child({ module: "context-management-extension" });
2095
2098
  function isAnthropicMessagesPayload(payload) {
2096
2099
  if (typeof payload !== "object" || payload === null) return false;
2097
2100
  const candidate = payload;
@@ -2156,13 +2159,13 @@ function createContextManagementExtension() {
2156
2159
  setContextManagementFlagOverride(getPolledFlag("contextManagement"));
2157
2160
  onFlagChange("contextManagement", (enabled) => {
2158
2161
  setContextManagementFlagOverride(enabled);
2159
- log$8.info({
2162
+ log$9.info({
2160
2163
  event: "context_management_flag_update",
2161
2164
  enabled
2162
2165
  }, "context-management flag updated from platform");
2163
2166
  });
2164
2167
  startFeatureFlagPoller();
2165
- log$8.info({
2168
+ log$9.info({
2166
2169
  event: "context_management_registered",
2167
2170
  enabled: initial.enabled,
2168
2171
  flagSource: hasFlagSource(),
@@ -2174,13 +2177,13 @@ function createContextManagementExtension() {
2174
2177
  const { messages } = event;
2175
2178
  try {
2176
2179
  const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
2177
- if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$8.info({
2180
+ if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$9.info({
2178
2181
  event: "context_management_applied",
2179
2182
  ...result.stats
2180
2183
  }, "trimmed/cleared tool output before LLM call");
2181
2184
  return { messages: result.messages };
2182
2185
  } catch (err) {
2183
- log$8.error({
2186
+ log$9.error({
2184
2187
  err,
2185
2188
  event: "context_management_transform_failed"
2186
2189
  }, "context transform failed; passing messages through unchanged");
@@ -2192,7 +2195,7 @@ function createContextManagementExtension() {
2192
2195
  }
2193
2196
  //#endregion
2194
2197
  //#region src/extensions/current-time.ts
2195
- const log$7 = logger.child({ module: "current-time-extension" });
2198
+ const log$8 = logger.child({ module: "current-time-extension" });
2196
2199
  const PI_DATE_LINE = /^Current date:.*$/m;
2197
2200
  function formatCurrentTimeLine(now) {
2198
2201
  return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
@@ -2205,7 +2208,7 @@ const currentTimeExtension = (pi) => {
2205
2208
  const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
2206
2209
  const base = event.systemPrompt;
2207
2210
  if (PI_DATE_LINE.test(base)) {
2208
- 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)");
2211
+ 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)");
2209
2212
  return { systemPrompt: base.replace(PI_DATE_LINE, line) };
2210
2213
  }
2211
2214
  return { systemPrompt: `${base}\n${line}` };
@@ -2442,7 +2445,7 @@ function renderIndex(entries) {
2442
2445
  }
2443
2446
  //#endregion
2444
2447
  //#region src/extensions/memory.ts
2445
- const log$6 = logger.child({ module: "memory-extension" });
2448
+ const log$7 = logger.child({ module: "memory-extension" });
2446
2449
  /**
2447
2450
  * The standing instructions for the memory system. Always injected (even with
2448
2451
  * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
@@ -2476,7 +2479,7 @@ const memoryExtension = (pi) => {
2476
2479
  index
2477
2480
  });
2478
2481
  } catch (err) {
2479
- log$6.warn({
2482
+ log$7.warn({
2480
2483
  err,
2481
2484
  event: "memory_index_failed"
2482
2485
  }, "memory index build failed; injecting instructions only");
@@ -2490,7 +2493,7 @@ const memoryExtension = (pi) => {
2490
2493
  };
2491
2494
  //#endregion
2492
2495
  //#region src/extensions/platform-memory.ts
2493
- const log$5 = logger.child({ module: "platform-memory-extension" });
2496
+ const log$6 = logger.child({ module: "platform-memory-extension" });
2494
2497
  /**
2495
2498
  * Resolve the human on this turn via the API, keyed by the message id.
2496
2499
  * `/sandbox/channel-context` only returns a sender for a platform-known
@@ -2503,13 +2506,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
2503
2506
  async function resolveTurnUser(messageId) {
2504
2507
  const client = sandboxClient();
2505
2508
  if (!client) {
2506
- log$5.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2509
+ log$6.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2507
2510
  return null;
2508
2511
  }
2509
2512
  try {
2510
2513
  const res = await client["channel-context"].$get({ query: { messageId } });
2511
2514
  if (!res.ok) {
2512
- log$5.warn({
2515
+ log$6.warn({
2513
2516
  event: "resolve_turn_user_failed",
2514
2517
  status: res.status
2515
2518
  }, "channel-context returned non-ok; withholding user memory");
@@ -2522,7 +2525,7 @@ async function resolveTurnUser(messageId) {
2522
2525
  displayName: sender.displayName
2523
2526
  };
2524
2527
  } catch (err) {
2525
- log$5.warn({
2528
+ log$6.warn({
2526
2529
  err,
2527
2530
  event: "resolve_turn_user_failed"
2528
2531
  }, "failed to resolve current user; withholding user memory");
@@ -2569,7 +2572,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2569
2572
  user
2570
2573
  });
2571
2574
  } catch (err) {
2572
- log$5.warn({
2575
+ log$6.warn({
2573
2576
  err,
2574
2577
  event: "user_memory_index_failed"
2575
2578
  }, "user memory index build failed; skipping injection");
@@ -2584,7 +2587,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2584
2587
  }
2585
2588
  //#endregion
2586
2589
  //#region src/extensions/self-trace.ts
2587
- const log$4 = logger.child({ module: "self-trace-extension" });
2590
+ const log$5 = logger.child({ module: "self-trace-extension" });
2588
2591
  /**
2589
2592
  * Reports the agent's own execution as OpenTelemetry spans:
2590
2593
  * agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
@@ -2609,7 +2612,7 @@ const selfTraceExtension = (pi) => {
2609
2612
  sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
2610
2613
  sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
2611
2614
  const sc = sessionSpan.spanContext();
2612
- log$4.info({
2615
+ log$5.info({
2613
2616
  event: "self_trace_session_start",
2614
2617
  trace_id: sc.traceId,
2615
2618
  span_id: sc.spanId,
@@ -2721,13 +2724,13 @@ const selfTraceExtension = (pi) => {
2721
2724
  * Lives in the harness package — soul.md is content from the agent's
2722
2725
  * own git repo, not from the platform — so its handling stays here.
2723
2726
  */
2724
- const log$3 = logger.child({ module: "soul-extension" });
2727
+ const log$4 = logger.child({ module: "soul-extension" });
2725
2728
  async function readSoul(cwd) {
2726
2729
  try {
2727
2730
  return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
2728
2731
  } catch (err) {
2729
2732
  if (err?.code === "ENOENT") return null;
2730
- log$3.warn({
2733
+ log$4.warn({
2731
2734
  err,
2732
2735
  event: "soul_read_failed"
2733
2736
  }, "soul.md read failed");
@@ -2759,7 +2762,7 @@ const soulExtension = (pi) => {
2759
2762
  };
2760
2763
  //#endregion
2761
2764
  //#region src/extensions/subagent/index.ts
2762
- const log$2 = logger.child({ module: "subagent-ext" });
2765
+ const log$3 = logger.child({ module: "subagent-ext" });
2763
2766
  const MAX_TASKS = 8;
2764
2767
  const TaskItem = Type.Object({
2765
2768
  task: Type.String({ description: "The task to delegate to a subagent run." }),
@@ -2818,7 +2821,7 @@ function buildTool(messageId) {
2818
2821
  tasks: spawnTasks
2819
2822
  });
2820
2823
  const { taskIds } = spawned;
2821
- log$2.info({
2824
+ log$3.info({
2822
2825
  event: "subagent_spawned",
2823
2826
  count: taskIds.length
2824
2827
  }, "subagent tasks queued");
@@ -2841,7 +2844,7 @@ function buildTool(messageId) {
2841
2844
  };
2842
2845
  } catch (err) {
2843
2846
  const message = err instanceof Error ? err.message : String(err);
2844
- log$2.warn({
2847
+ log$3.warn({
2845
2848
  err,
2846
2849
  event: "subagent_spawn_failed"
2847
2850
  }, "subagent spawn failed");
@@ -2872,7 +2875,7 @@ function createSubagentExtension({ channelContext }) {
2872
2875
  if (registered) return;
2873
2876
  registered = true;
2874
2877
  pi.registerTool(buildTool(messageId));
2875
- log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
2878
+ log$3.info({ event: "subagent_enabled" }, "subagent tool registered");
2876
2879
  };
2877
2880
  pi.on("session_start", () => {
2878
2881
  registerOnce();
@@ -2880,6 +2883,218 @@ function createSubagentExtension({ channelContext }) {
2880
2883
  };
2881
2884
  }
2882
2885
  //#endregion
2886
+ //#region src/extensions/resource-pressure-warning.ts
2887
+ /**
2888
+ * Mid-run resource-pressure warning to the agent.
2889
+ *
2890
+ * The sandbox already detects pressure — the boot scripts cap the
2891
+ * user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
2892
+ * edges for memory and disk — but nothing told the *agent*, so a turn burned
2893
+ * straight to the OOM kill (or a full disk) and only learned about it from
2894
+ * the post-mortem notice. This extension closes that gap in-process: while a
2895
+ * turn is active it polls the agent cgroup and the root filesystem and, the
2896
+ * first time usage crosses a warn threshold, folds a system notification into
2897
+ * the open turn so the agent can checkpoint, shed work (constrain
2898
+ * parallelism, kill a background hog, clean scratch space), or request a
2899
+ * bigger tier BEFORE the kill.
2900
+ *
2901
+ * The notification is triggered by the two conditions that actually kill
2902
+ * work — memory near the cgroup hard cap, disk near full — and reports a
2903
+ * snapshot of all the relevant stats (memory, CPU utilization, disk) so the
2904
+ * agent can tell which resource is the problem and how much headroom the
2905
+ * others have.
2906
+ *
2907
+ * Edge-triggered, once per trigger per turn: the fired flags reset on
2908
+ * agent_start, so a turn that rides a threshold gets one warning per
2909
+ * resource, not a stream. Polling only runs while the agent is active — an
2910
+ * idle sandbox's resource usage is not the agent's problem and there is no
2911
+ * open turn to deliver into anyway.
2912
+ *
2913
+ * Best-effort throughout: any read failure (cgroup absent, controller not
2914
+ * delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
2915
+ * stat and the extension warns on what it can see — it must never break a
2916
+ * turn over an observability feature.
2917
+ */
2918
+ const execFileAsync = promisify(execFile);
2919
+ const log$2 = logger.child({ module: "resource-pressure-warning" });
2920
+ const POLL_INTERVAL_MS = 1e4;
2921
+ function envOverride(name) {
2922
+ for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
2923
+ const value = process.env[`${prefix}${name}`];
2924
+ if (value != null && value !== "") return value;
2925
+ }
2926
+ return null;
2927
+ }
2928
+ function cgroupDir() {
2929
+ return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
2930
+ }
2931
+ function diskRoot() {
2932
+ return envOverride("DISK_ROOT") ?? "/";
2933
+ }
2934
+ /**
2935
+ * Read a cgroup v2 scalar file. Returns a number, or null for "max"
2936
+ * (uncapped), an empty/absent file, or any read/parse error — an uncapped or
2937
+ * unreadable limit means there is nothing meaningful to warn against.
2938
+ */
2939
+ async function readScalar(file) {
2940
+ try {
2941
+ const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
2942
+ if (raw === "" || raw === "max") return null;
2943
+ const n = Number(raw);
2944
+ return Number.isFinite(n) ? n : null;
2945
+ } catch (_error) {
2946
+ return null;
2947
+ }
2948
+ }
2949
+ /**
2950
+ * Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
2951
+ * cpu.stat) and return the counter for `key`, or null when absent.
2952
+ */
2953
+ async function readKeyedCounter(file, key) {
2954
+ try {
2955
+ const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
2956
+ for (const line of raw.split("\n")) {
2957
+ const [k, v] = line.trim().split(/\s+/);
2958
+ if (k === key) {
2959
+ const n = Number(v);
2960
+ return Number.isFinite(n) ? n : null;
2961
+ }
2962
+ }
2963
+ return null;
2964
+ } catch (_error) {
2965
+ return null;
2966
+ }
2967
+ }
2968
+ /**
2969
+ * Live memory usage as an integer percent of the hard cap, or null when
2970
+ * either side is unreadable/uncapped. Exported for tests.
2971
+ */
2972
+ async function readMemUsePct() {
2973
+ const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
2974
+ if (current === null || max === null || max <= 0) return null;
2975
+ return {
2976
+ pct: Math.floor(current / max * 100),
2977
+ currentBytes: current,
2978
+ maxBytes: max
2979
+ };
2980
+ }
2981
+ /**
2982
+ * Root filesystem used% (df -P Capacity column), or null on any failure.
2983
+ * Exported for tests.
2984
+ */
2985
+ async function readDiskUsePct() {
2986
+ try {
2987
+ const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
2988
+ const dataRow = stdout.trim().split("\n")[1];
2989
+ if (dataRow == null) return null;
2990
+ const capacity = dataRow.trim().split(/\s+/)[4];
2991
+ if (capacity == null) return null;
2992
+ const pct = Number(capacity.replace("%", ""));
2993
+ return Number.isFinite(pct) ? pct : null;
2994
+ } catch (_error) {
2995
+ return null;
2996
+ }
2997
+ }
2998
+ /**
2999
+ * CPU utilization sampler. cgroup v2 exposes cumulative CPU time
3000
+ * (cpu.stat usage_usec); utilization is the delta between two samples over
3001
+ * the wall time between them, normalized by core count. The first call after
3002
+ * construction has no previous sample and returns null.
3003
+ */
3004
+ function createCpuSampler() {
3005
+ let prevUsageUsec = null;
3006
+ let prevAtMs = null;
3007
+ return async () => {
3008
+ const usage = await readKeyedCounter("cpu.stat", "usage_usec");
3009
+ const now = Date.now();
3010
+ const prev = prevUsageUsec;
3011
+ const prevAt = prevAtMs;
3012
+ prevUsageUsec = usage;
3013
+ prevAtMs = now;
3014
+ if (usage === null || prev === null || prevAt === null) return null;
3015
+ const wallUsec = (now - prevAt) * 1e3;
3016
+ if (wallUsec <= 0) return null;
3017
+ const cores = availableParallelism();
3018
+ const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
3019
+ return Math.max(0, Math.min(100, pct));
3020
+ };
3021
+ }
3022
+ function fmtMb(bytes) {
3023
+ return Math.round(bytes / 1024 / 1024);
3024
+ }
3025
+ /** The model-facing warning text. Exported for tests. */
3026
+ function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
3027
+ const stats = [];
3028
+ if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
3029
+ if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
3030
+ if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
3031
+ 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.`;
3032
+ 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.";
3033
+ 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>`;
3034
+ }
3035
+ const resourcePressureWarningExtension = (pi) => {
3036
+ let agentActive = false;
3037
+ let warnedMemThisTurn = false;
3038
+ let warnedDiskThisTurn = false;
3039
+ let timer = null;
3040
+ const sampleCpu = createCpuSampler();
3041
+ async function checkOnce() {
3042
+ if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
3043
+ const [mem, cpuPct, diskPct] = await Promise.all([
3044
+ readMemUsePct(),
3045
+ sampleCpu(),
3046
+ readDiskUsePct()
3047
+ ]);
3048
+ let trigger = null;
3049
+ if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
3050
+ trigger = "memory";
3051
+ warnedMemThisTurn = true;
3052
+ } else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
3053
+ trigger = "disk";
3054
+ warnedDiskThisTurn = true;
3055
+ }
3056
+ if (trigger === null) return;
3057
+ log$2.warn({
3058
+ trigger,
3059
+ mem,
3060
+ cpuPct,
3061
+ diskPct
3062
+ }, "resource pressure warning delivered to agent");
3063
+ await pi.sendMessage({
3064
+ customType: "anyone-resource-pressure-warning",
3065
+ content: resourcePressureWarningText(trigger, {
3066
+ mem,
3067
+ cpuPct,
3068
+ diskPct
3069
+ }),
3070
+ display: false
3071
+ }, {
3072
+ triggerTurn: true,
3073
+ deliverAs: "followUp"
3074
+ });
3075
+ }
3076
+ pi.on("agent_start", async () => {
3077
+ agentActive = true;
3078
+ warnedMemThisTurn = false;
3079
+ warnedDiskThisTurn = false;
3080
+ if (!timer) {
3081
+ timer = setInterval(() => {
3082
+ checkOnce().catch((err) => {
3083
+ log$2.error({ err }, "resource pressure check failed");
3084
+ });
3085
+ }, POLL_INTERVAL_MS);
3086
+ timer.unref?.();
3087
+ }
3088
+ });
3089
+ pi.on("agent_end", async () => {
3090
+ agentActive = false;
3091
+ if (timer) {
3092
+ clearInterval(timer);
3093
+ timer = null;
3094
+ }
3095
+ });
3096
+ };
3097
+ //#endregion
2883
3098
  //#region src/extensions/tool-call-env.ts
2884
3099
  const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
2885
3100
  function shellQuoteValue(value) {
@@ -3653,7 +3868,8 @@ function platformExtensions({ sessionId, channelContext }) {
3653
3868
  selfTraceExtension,
3654
3869
  createBackgroundTasksExtension({ channelContext }),
3655
3870
  createSubagentExtension({ channelContext }),
3656
- createContextManagementExtension()
3871
+ createContextManagementExtension(),
3872
+ resourcePressureWarningExtension
3657
3873
  ];
3658
3874
  }
3659
3875
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.751",
3
+ "version": "0.1.0-beta.753",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",