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