@skydiveai/pi-extensions 0.1.0-beta.16 → 0.1.0-beta.1601

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 +1035 -212
  2. package/package.json +3 -9
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
3
3
  import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
4
4
  import { buildAgentCard, chainMiddleware, composeHandlers, createAgentExecutor, createProtocolHandlers, getCurrentTraceparent, logger, mountAt, requestHeaders, requestUrl, webHandlerToMiddleware } from "@skydiveai/pi-server";
5
- import { mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises";
5
+ import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
6
6
  import { basename, dirname, join, relative, resolve } from "node:path";
7
7
  import { z } from "zod";
8
8
  import { pathToFileURL } from "node:url";
@@ -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$13 = logger.child({ module: "context-management-config" });
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$13.warn({
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$12 = logger.child({ module: "local-tools-extension" });
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$12.info({
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$12.error({
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$12.warn({
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$12.error({
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 instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401) return {
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$11 = logger.child({ module: "mcp-extension" });
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$11.info({
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$11.error({
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$11.warn({
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$11.error({
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 only a reference — `{ channel, messageId }` — into the
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) in just to read
1650
- * two fields. So we validate the (stable) shape locally instead.
2088
+ * platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
2089
+ * field. So we validate the field this consumer needs locally instead.
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
- * 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
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
- const body = await res.json();
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,
@@ -1764,6 +2264,19 @@ async function postBackgroundTaskDone({ messageId, content }) {
1764
2264
  } });
1765
2265
  if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
1766
2266
  }
2267
+ function postBackgroundTasksSnapshot({ messageId, tasks }) {
2268
+ const client = sandboxClient();
2269
+ if (!client || !messageId) return;
2270
+ client["bg-tasks"].$post({ json: {
2271
+ messageId,
2272
+ tasks
2273
+ } }).catch((err) => {
2274
+ log$10.debug({
2275
+ err,
2276
+ event: "bg_tasks_snapshot_failed"
2277
+ }, "bg-tasks snapshot publish failed");
2278
+ });
2279
+ }
1767
2280
  async function postSubagentSpawn({ messageId, tasks }) {
1768
2281
  const client = sandboxClient();
1769
2282
  if (!client) throw new Error("no api url for subagent-spawn");
@@ -1771,8 +2284,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
1771
2284
  messageId,
1772
2285
  tasks
1773
2286
  } });
1774
- if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
1775
- return { taskIds: (await res.json()).taskIds };
2287
+ if (!res.ok) {
2288
+ let detail = "";
2289
+ try {
2290
+ const errBody = await res.json();
2291
+ if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
2292
+ } catch {}
2293
+ throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
2294
+ }
2295
+ const body = await res.json();
2296
+ return {
2297
+ taskIds: body.taskIds,
2298
+ tasks: body.tasks ?? []
2299
+ };
1776
2300
  }
1777
2301
  function createHeartbeatThrottle({ messageId }) {
1778
2302
  let lastAt = 0;
@@ -1921,20 +2445,16 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1921
2445
  * Shared harness feature-flag poll.
1922
2446
  *
1923
2447
  * The api exposes one `/feature-flags` GET that returns every harness flag in a
1924
- * single response (`{ contextManagement, subagent, commandFlags }` — see
2448
+ * single response (`{ contextManagement, commandFlags }` — see
1925
2449
  * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1926
2450
  * extension issuing its own GET — and, worse, a *blocking* GET on the
1927
2451
  * pre-first-token `session_start` path — a single background poller fetches
1928
2452
  * that response once per interval and fans the values out to every subscriber.
1929
2453
  *
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.
2454
+ * Why one poller: context-management consumes the `contextManagement` flag
2455
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
2456
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
2457
+ * as `null` and a newly-flipped flag takes effect on the next poll.
1938
2458
  *
1939
2459
  * The poll is fire-and-forget and self-unref'd — it never keeps the process
1940
2460
  * alive and an indeterminate result (no api url / transient failure) leaves the
@@ -1943,15 +2463,21 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1943
2463
  const log$9 = logger.child({ module: "feature-flags-poll" });
1944
2464
  const FLAG_POLL_INTERVAL_MS = 6e4;
1945
2465
  let contextManagement = null;
1946
- let subagent = null;
1947
- const subscribers = {
1948
- contextManagement: /* @__PURE__ */ new Set(),
1949
- subagent: /* @__PURE__ */ new Set()
1950
- };
2466
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
1951
2467
  let pollerStarted = false;
2468
+ let firstPollSettled = false;
2469
+ let resolveFirstPoll = null;
2470
+ new Promise((resolve) => {
2471
+ resolveFirstPoll = resolve;
2472
+ });
2473
+ function markFirstPollSettled() {
2474
+ if (firstPollSettled) return;
2475
+ firstPollSettled = true;
2476
+ resolveFirstPoll?.();
2477
+ }
1952
2478
  /** Last-polled value of a flag, or `null` if not yet resolved. */
1953
- function getPolledFlag(name) {
1954
- return name === "contextManagement" ? contextManagement : subagent;
2479
+ function getPolledFlag(_name) {
2480
+ return contextManagement;
1955
2481
  }
1956
2482
  /**
1957
2483
  * Subscribe to changes of a flag. The callback fires only on a *transition*
@@ -1964,9 +2490,8 @@ function onFlagChange(name, cb) {
1964
2490
  }
1965
2491
  function apply(name, next) {
1966
2492
  if (next === null) return;
1967
- const prev = name === "contextManagement" ? contextManagement : subagent;
1968
- if (name === "contextManagement") contextManagement = next;
1969
- else subagent = next;
2493
+ const prev = contextManagement;
2494
+ contextManagement = next;
1970
2495
  if (next !== prev) for (const cb of subscribers[name]) try {
1971
2496
  cb(next);
1972
2497
  } catch (err) {
@@ -1977,10 +2502,13 @@ function apply(name, next) {
1977
2502
  }
1978
2503
  }
1979
2504
  async function pollOnce() {
1980
- const flags = await fetchHarnessFlags();
1981
- if (!flags) return;
1982
- apply("contextManagement", flags.contextManagement ?? null);
1983
- apply("subagent", flags.subagent ?? null);
2505
+ try {
2506
+ const flags = await fetchHarnessFlags();
2507
+ if (!flags) return;
2508
+ apply("contextManagement", flags.contextManagement ?? null);
2509
+ } catch (err) {
2510
+ log$9.debug({ err }, "feature-flag poll threw");
2511
+ }
1984
2512
  }
1985
2513
  /**
1986
2514
  * Start the shared background poll (idempotent). No-op when there's no
@@ -1991,7 +2519,7 @@ async function pollOnce() {
1991
2519
  function startFeatureFlagPoller() {
1992
2520
  if (pollerStarted || !hasFlagSource()) return;
1993
2521
  pollerStarted = true;
1994
- pollOnce();
2522
+ pollOnce().finally(markFirstPollSettled);
1995
2523
  setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
1996
2524
  }
1997
2525
  //#endregion
@@ -2209,37 +2737,35 @@ const currentTimeExtension = (pi) => {
2209
2737
  //#endregion
2210
2738
  //#region src/memory.ts
2211
2739
  /**
2212
- * In-harness memory index builder.
2213
- *
2214
- * The agent has an agent-level file-based memory at `<cwd>/.memory/`,
2215
- * organized by directory:
2740
+ * In-harness memory readers.
2216
2741
  *
2217
- * .memory/users/<id>-<name>/<topic>.md
2218
- * .memory/projects/<project_slug>/<topic>.md
2219
- * .memory/feedback/<topic>.md
2220
- * .memory/reference/<topic>.md
2742
+ * The agent has an agent-level file-based memory at `<cwd>/.memory/`, split
2743
+ * into two halves that are surfaced differently:
2221
2744
  *
2222
- * The path encodes type and subject (for `users/`, the subject is the
2223
- * person's stable id with a readable name suffix); each `.md` file's
2224
- * frontmatter only carries `name` and `description`.
2745
+ * 1. Shared knowledge (projects, lessons, external systems) indexed by a
2746
+ * single hand-maintained `<cwd>/.memory/MEMORY.md` that the *agent* writes
2747
+ * and curates, Claude-Code style: one line per fact pointing at the file
2748
+ * that holds it. `readMemoryIndexFile` just reads that file; the agent owns
2749
+ * its contents. This is the whole index for the shared half — there is no
2750
+ * derived walk and no per-directory `MEMORY.md`.
2225
2751
  *
2226
- * `buildMemoryIndex` walks `.memory/` by type directory, reads only the
2227
- * frontmatter of each `.md` (open fd read first ~4KB → close, in
2228
- * parallel), and renders a markdown index grouped by type and (where
2229
- * applicable) by subject. Files outside the four type directories are
2230
- * ignored. Bodies are never read — the agent loads a specific memory's
2231
- * body on demand via the `read` tool when the index entry says it's
2232
- * relevant.
2752
+ * 2. Per-person memory `.memory/users/<id>-<name>/<topic>.md`. This half is
2753
+ * *derived*, not hand-maintained, because it has to be filtered to the one
2754
+ * person on the current turn (a single hand-written index couldn't be
2755
+ * scoped per-user without leaking one person's notes into another's
2756
+ * conversation). `buildMemoryIndex` walks a single user's directory, reads
2757
+ * only the frontmatter of each `.md` (open fd read first ~4KB → close, in
2758
+ * parallel), and renders an index. Each `.md`'s frontmatter carries `name`
2759
+ * and `description`; bodies are never read — the agent loads a specific
2760
+ * memory's body on demand via the `read` tool.
2233
2761
  *
2234
- * Mtime cache keyed by cwd — within the lifetime of a sandbox the cwd
2235
- * is fixed, so this is effectively a single-entry cache. Cache invalidates
2236
- * when any `.md` in the tree is added/modified/deleted; turns where
2237
- * memory didn't change reuse the cached string.
2762
+ * Mtime cache (for the derived per-user half) keyed by cwd — within the
2763
+ * lifetime of a sandbox the cwd is fixed, so this is effectively a single-entry
2764
+ * cache. It invalidates when any `.md` under `users/` is added/modified/
2765
+ * deleted; turns where memory didn't change reuse the cached entries.
2238
2766
  *
2239
- * Frontmatter is parsed as YAML (`yaml` package) and validated with a
2240
- * zod schema — files that don't match the shape are dropped from the
2241
- * index. The same schema can be reused at write time if we want to
2242
- * validate before commit.
2767
+ * Frontmatter is parsed as YAML (`yaml` package) and validated with a zod
2768
+ * schema — files that don't match the shape are dropped from the index.
2243
2769
  */
2244
2770
  const FRONTMATTER_READ_BYTES = 4096;
2245
2771
  const FrontmatterSchema = z.object({
@@ -2247,33 +2773,118 @@ const FrontmatterSchema = z.object({
2247
2773
  description: z.string().min(1)
2248
2774
  }).passthrough();
2249
2775
  const MEMORY_DIRNAME = ".memory";
2250
- const TYPE_DIRS = [
2251
- "users",
2252
- "projects",
2253
- "feedback",
2254
- "reference"
2776
+ const MEMORY_INDEX_FILENAME = "MEMORY.md";
2777
+ const USERS_DIRNAME = "users";
2778
+ /**
2779
+ * The shared-knowledge type dirs from the old frontmatter-indexed layout, used
2780
+ * only to seed a `MEMORY.md` for agents created before it existed (see
2781
+ * `seedMemoryIndexFile`). `users/` is deliberately excluded — per-person memory
2782
+ * stays derived and never lands in the shared, un-scoped `MEMORY.md`.
2783
+ */
2784
+ const LEGACY_SHARED_TYPES = [
2785
+ {
2786
+ dir: "projects",
2787
+ label: "Projects"
2788
+ },
2789
+ {
2790
+ dir: "feedback",
2791
+ label: "Feedback"
2792
+ },
2793
+ {
2794
+ dir: "reference",
2795
+ label: "Reference"
2796
+ }
2255
2797
  ];
2256
- const TYPES_WITH_SUBJECT = new Set(["users", "projects"]);
2257
- const TYPE_LABELS = {
2258
- users: "Users",
2259
- projects: "Projects",
2260
- feedback: "Feedback",
2261
- reference: "Reference"
2262
- };
2798
+ /**
2799
+ * Soft budget for an injected index block. The index is read and injected into
2800
+ * the system prompt on every turn, so every entry costs context for the rest of
2801
+ * the conversation. Past this size we nudge the agent to consolidate and prune
2802
+ * rather than keep appending. Not a hard cap — nothing is truncated.
2803
+ */
2804
+ const MEMORY_INDEX_SOFT_BUDGET_CHARS = 2e4;
2805
+ /**
2806
+ * Hard cap on the injected index — double the soft budget. The soft budget only
2807
+ * warns; this actually bounds what we inject so a runaway index can't consume
2808
+ * unbounded context on every turn. Past this, the index is truncated (on a line
2809
+ * boundary) before injection. It's a backstop, not a normal operating point.
2810
+ */
2811
+ const MEMORY_INDEX_HARD_BUDGET_CHARS = MEMORY_INDEX_SOFT_BUDGET_CHARS * 2;
2812
+ /**
2813
+ * Cheap size summary of a rendered index block, used to surface how much of the
2814
+ * every-turn context budget the index is spending so the agent keeps it lean.
2815
+ * Counts pointer/file lines — both the derived `` - `path` — desc`` form and
2816
+ * the hand-maintained `- [Title](path) — hook` form — not the group headers.
2817
+ */
2818
+ function summarizeIndex(index) {
2819
+ const entryCount = index.split("\n").filter((line) => /^\s*- (?:`|\[)/.test(line)).length;
2820
+ const charCount = index.length;
2821
+ return {
2822
+ entryCount,
2823
+ charCount,
2824
+ overBudget: charCount > MEMORY_INDEX_SOFT_BUDGET_CHARS
2825
+ };
2826
+ }
2827
+ /**
2828
+ * One-line size note for an index block header, e.g. `12 entries, 3187 chars`.
2829
+ */
2830
+ function indexSizeNote(index) {
2831
+ const { entryCount, charCount } = summarizeIndex(index);
2832
+ return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}, ${charCount} chars`;
2833
+ }
2834
+ /**
2835
+ * An explicit warning to surface to the agent when an index has grown past its
2836
+ * budget, or `null` when it's within budget. Extensions render this prominently
2837
+ * above the index so the agent prunes before it keeps appending.
2838
+ */
2839
+ function indexBudgetWarning(index) {
2840
+ const { charCount, overBudget } = summarizeIndex(index);
2841
+ if (!overBudget) return null;
2842
+ return `⚠️ This memory index is ${charCount} chars, over its ${MEMORY_INDEX_SOFT_BUDGET_CHARS}-char budget. It's costing you context on every turn — consolidate duplicate entries and delete stale ones to bring it back under budget before adding anything new.`;
2843
+ }
2844
+ /**
2845
+ * Enforce the hard cap on an index before injection. Under the cap the index is
2846
+ * returned unchanged; over it, the index is truncated on a line boundary and a
2847
+ * notice is appended naming the true size so the agent knows entries are hidden
2848
+ * and must be pruned. This is the actual bound on injected context — callers
2849
+ * still report the true size via {@link indexSizeNote} so nothing is masked.
2850
+ */
2851
+ function enforceMemoryIndexHardBudget(index) {
2852
+ if (index.length <= 4e4) return index;
2853
+ const clipped = index.slice(0, MEMORY_INDEX_HARD_BUDGET_CHARS);
2854
+ const lastNewline = clipped.lastIndexOf("\n");
2855
+ return `${lastNewline > 0 ? clipped.slice(0, lastNewline) : clipped}\n\n⚠️ Memory index truncated at ${MEMORY_INDEX_HARD_BUDGET_CHARS} chars (it is ${index.length}). Entries past this point are NOT shown. Prune the index now — delete stale entries and consolidate duplicates.`;
2856
+ }
2857
+ /**
2858
+ * Read the agent's hand-maintained shared index at `.memory/MEMORY.md`.
2859
+ * Returns the trimmed contents, or `null` when the file is absent or empty —
2860
+ * the agent owns this file, so we surface exactly what it wrote.
2861
+ */
2862
+ async function readMemoryIndexFile({ cwd }) {
2863
+ const path = join(cwd, MEMORY_DIRNAME, MEMORY_INDEX_FILENAME);
2864
+ try {
2865
+ const trimmed = (await readFile(path, "utf-8")).trim();
2866
+ return trimmed.length > 0 ? trimmed : null;
2867
+ } catch {
2868
+ return null;
2869
+ }
2870
+ }
2263
2871
  const cache = /* @__PURE__ */ new Map();
2264
2872
  /**
2873
+ * Build the derived per-person index for a single user.
2874
+ *
2265
2875
  * Returns:
2266
- * - `null` if `.memory/` doesn't exist
2267
- * - `""` if the dir exists but contains nothing in the requested scope
2876
+ * - `null` if `.memory/users/` doesn't exist
2877
+ * - `""` if it exists but this user has no memory
2268
2878
  * - rendered markdown body (no surrounding header — caller wraps)
2269
2879
  *
2270
- * The mtime-keyed cache stores the raw walked entries (the cost is the FS
2271
- * walk); filtering by scope is cheap and runs per call, so two turns with
2272
- * different scopes on the same cwd render correctly from one cached walk.
2880
+ * Scoping by id keeps one person's memory from bleeding into another's
2881
+ * conversation. The mtime-keyed cache stores the raw walked entries (the cost
2882
+ * is the FS walk); filtering by user is cheap and runs per call, so two turns
2883
+ * with different users on the same cwd render correctly from one cached walk.
2273
2884
  */
2274
- async function buildMemoryIndex({ cwd, scope }) {
2275
- const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
2276
- const maxMtimeMs = await maxMtimeAcrossDir(memoryDirAbs);
2885
+ async function buildMemoryIndex({ cwd, userId }) {
2886
+ const usersDirAbs = join(cwd, MEMORY_DIRNAME, USERS_DIRNAME);
2887
+ const maxMtimeMs = await maxMtimeAcrossDir(usersDirAbs);
2277
2888
  if (maxMtimeMs === null) {
2278
2889
  cache.delete(cwd);
2279
2890
  return null;
@@ -2281,13 +2892,13 @@ async function buildMemoryIndex({ cwd, scope }) {
2281
2892
  let cached = cache.get(cwd);
2282
2893
  if (!cached || cached.builtAtMs < maxMtimeMs) {
2283
2894
  cached = {
2284
- entries: await collectEntries(memoryDirAbs, cwd),
2895
+ entries: await collectUserEntries(usersDirAbs, cwd),
2285
2896
  builtAtMs: Date.now()
2286
2897
  };
2287
2898
  cache.set(cwd, cached);
2288
2899
  }
2289
- const visible = cached.entries.filter((entry) => scope.kind === "user" ? entry.type === "users" && entry.subject?.startsWith(scope.userId) === true : entry.type !== "users");
2290
- return visible.length === 0 ? "" : renderIndex(visible);
2900
+ const visible = cached.entries.filter((entry) => entry.subject.startsWith(userId));
2901
+ return visible.length === 0 ? "" : renderUserIndex(visible);
2291
2902
  }
2292
2903
  async function maxMtimeAcrossDir(dir) {
2293
2904
  let dirStat;
@@ -2335,45 +2946,22 @@ async function listSubdirs(dir) {
2335
2946
  }
2336
2947
  return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
2337
2948
  }
2338
- async function collectEntries(rootDirAbs, cwd) {
2339
- const collected = [];
2340
- await Promise.all(TYPE_DIRS.map(async (type) => {
2341
- const typeDirAbs = join(rootDirAbs, type);
2342
- if (TYPES_WITH_SUBJECT.has(type)) {
2343
- const subjectDirs = await listSubdirs(typeDirAbs);
2344
- await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
2345
- const subject = basename(subjectDirAbs);
2346
- const files = await listMdFilesShallow(subjectDirAbs);
2347
- const parsed = await Promise.all(files.map(async (file) => {
2348
- const fm = await readFrontmatterOnly(file);
2349
- if (!fm?.name || !fm?.description) return null;
2350
- return {
2351
- name: fm.name,
2352
- description: fm.description,
2353
- type,
2354
- subject,
2355
- relPath: relative(cwd, file)
2356
- };
2357
- }));
2358
- for (const e of parsed) if (e) collected.push(e);
2359
- }));
2360
- } else {
2361
- const files = await listMdFilesShallow(typeDirAbs);
2362
- const parsed = await Promise.all(files.map(async (file) => {
2363
- const fm = await readFrontmatterOnly(file);
2364
- if (!fm?.name || !fm?.description) return null;
2365
- return {
2366
- name: fm.name,
2367
- description: fm.description,
2368
- type,
2369
- subject: null,
2370
- relPath: relative(cwd, file)
2371
- };
2372
- }));
2373
- for (const e of parsed) if (e) collected.push(e);
2374
- }
2375
- }));
2376
- return collected;
2949
+ async function collectUserEntries(usersDirAbs, cwd) {
2950
+ const subjectDirs = await listSubdirs(usersDirAbs);
2951
+ return (await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
2952
+ const subject = basename(subjectDirAbs);
2953
+ const files = await listMdFilesShallow(subjectDirAbs);
2954
+ return (await Promise.all(files.map(async (file) => {
2955
+ const fm = await readFrontmatterOnly(file);
2956
+ if (!fm?.name || !fm?.description) return null;
2957
+ return {
2958
+ name: fm.name,
2959
+ description: fm.description,
2960
+ subject,
2961
+ relPath: relative(cwd, file)
2962
+ };
2963
+ }))).filter((e) => e !== null);
2964
+ }))).flat();
2377
2965
  }
2378
2966
  async function readFrontmatterOnly(filePath) {
2379
2967
  let fh;
@@ -2404,68 +2992,180 @@ function parseFrontmatter(text) {
2404
2992
  const result = FrontmatterSchema.safeParse(parsed);
2405
2993
  return result.success ? result.data : null;
2406
2994
  }
2407
- function renderIndex(entries) {
2408
- const byType = {
2409
- users: [],
2410
- projects: [],
2411
- feedback: [],
2412
- reference: []
2413
- };
2414
- for (const e of entries) byType[e.type].push(e);
2995
+ function renderUserIndex(entries) {
2996
+ const bySubject = /* @__PURE__ */ new Map();
2997
+ for (const e of entries) {
2998
+ const list = bySubject.get(e.subject) ?? [];
2999
+ list.push(e);
3000
+ bySubject.set(e.subject, list);
3001
+ }
3002
+ const lines = ["### Users"];
3003
+ for (const subject of [...bySubject.keys()].sort()) {
3004
+ lines.push(`- **${subject}**`);
3005
+ for (const e of bySubject.get(subject) ?? []) lines.push(` - \`${e.relPath}\` — ${e.description}`);
3006
+ }
3007
+ return lines.join("\n");
3008
+ }
3009
+ /**
3010
+ * One-time migration for agents created before `MEMORY.md` existed. If there is
3011
+ * no hand-maintained `.memory/MEMORY.md` yet but the agent has shared memory
3012
+ * files from the old frontmatter-indexed layout (`projects/`, `feedback/`,
3013
+ * `reference/`), derive a `MEMORY.md` from their frontmatter and write it once.
3014
+ * After that the agent owns the file — this never runs again for that agent and
3015
+ * never clobbers an existing index.
3016
+ *
3017
+ * Returns the seeded contents (also written to disk), or `null` when nothing
3018
+ * was seeded (index already present, or no legacy shared files). A write
3019
+ * failure propagates so the caller can log it; the read path then falls back to
3020
+ * whatever is on disk.
3021
+ */
3022
+ async function seedMemoryIndexFile({ cwd }) {
3023
+ const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
3024
+ const indexPath = join(memoryDirAbs, MEMORY_INDEX_FILENAME);
3025
+ if (await stat(indexPath).catch(() => null)) return null;
3026
+ const perType = await Promise.all(LEGACY_SHARED_TYPES.map(async ({ dir, label }) => {
3027
+ const typeDirAbs = join(memoryDirAbs, dir);
3028
+ const files = [];
3029
+ await walkMdFiles(typeDirAbs, files);
3030
+ return {
3031
+ label,
3032
+ entries: (await Promise.all(files.map(async (file) => {
3033
+ const fm = await readFrontmatterOnly(file);
3034
+ if (!fm?.name || !fm?.description) return null;
3035
+ return {
3036
+ name: fm.name,
3037
+ description: fm.description,
3038
+ relPath: relative(cwd, file)
3039
+ };
3040
+ }))).filter((e) => !!e)
3041
+ };
3042
+ }));
3043
+ if (perType.every((group) => group.entries.length === 0)) return null;
3044
+ const content = renderSeededIndex(perType);
3045
+ await writeFile(indexPath, `${content}\n`, "utf-8");
3046
+ return content;
3047
+ }
3048
+ function renderSeededIndex(groups) {
2415
3049
  const sections = [];
2416
- for (const type of TYPE_DIRS) {
2417
- const items = byType[type];
2418
- if (items.length === 0) continue;
2419
- sections.push(`### ${TYPE_LABELS[type]}`);
2420
- if (TYPES_WITH_SUBJECT.has(type)) {
2421
- const bySubject = /* @__PURE__ */ new Map();
2422
- for (const e of items) {
2423
- const subject = e.subject ?? "(unknown)";
2424
- const list = bySubject.get(subject) ?? [];
2425
- list.push(e);
2426
- bySubject.set(subject, list);
2427
- }
2428
- const subjects = [...bySubject.keys()].sort();
2429
- for (const subject of subjects) {
2430
- sections.push(`- **${subject}**`);
2431
- for (const e of bySubject.get(subject) ?? []) sections.push(` - \`${e.relPath}\` — ${e.description}`);
2432
- }
2433
- } else for (const e of items) sections.push(`- \`${e.relPath}\` — ${e.description}`);
3050
+ for (const { label, entries } of groups) {
3051
+ if (entries.length === 0) continue;
3052
+ sections.push(`### ${label}`);
3053
+ const sorted = [...entries].sort((a, b) => a.relPath.localeCompare(b.relPath));
3054
+ for (const e of sorted) sections.push(`- [${e.name}](${e.relPath}) — ${e.description}`);
2434
3055
  sections.push("");
2435
3056
  }
2436
3057
  return sections.join("\n").trimEnd();
2437
3058
  }
3059
+ /**
3060
+ * The version at which the hand-maintained `MEMORY.md` layout was introduced.
3061
+ * Used to classify an unversioned `.memory/`: if it already has a `MEMORY.md`
3062
+ * it's on this layout (not the pre-MEMORY.md v1 frontmatter layout), so it
3063
+ * shouldn't be treated as v1 and re-seeded.
3064
+ */
3065
+ const HAND_MAINTAINED_INDEX_VERSION = 2;
3066
+ const VERSION_FILENAME = ".version";
3067
+ const MEMORY_MIGRATIONS = [{
3068
+ from: 1,
3069
+ to: 2,
3070
+ apply: async ({ cwd }) => {
3071
+ await seedMemoryIndexFile({ cwd });
3072
+ }
3073
+ }];
3074
+ /**
3075
+ * The layout version of an agent's `.memory/`:
3076
+ * - `null` when there's no `.memory/` at all (a fresh agent is current by
3077
+ * construction; nothing to migrate).
3078
+ * - `1` when `.memory/` exists but carries no `.version` marker — i.e. it
3079
+ * predates versioning.
3080
+ * - otherwise the integer in `.memory/.version`.
3081
+ */
3082
+ async function readMemoryVersion(cwd) {
3083
+ const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
3084
+ if (!(await stat(memoryDirAbs).catch(() => null))?.isDirectory()) return null;
3085
+ const raw = await readFile(join(memoryDirAbs, VERSION_FILENAME), "utf-8").catch(() => null);
3086
+ if (raw === null) return await stat(join(memoryDirAbs, MEMORY_INDEX_FILENAME)).then((s) => s.isFile()).catch(() => false) ? HAND_MAINTAINED_INDEX_VERSION : 1;
3087
+ const parsed = Number.parseInt(raw.trim(), 10);
3088
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
3089
+ }
3090
+ async function writeMemoryVersion(cwd, version) {
3091
+ await writeFile(join(cwd, MEMORY_DIRNAME, VERSION_FILENAME), `${version}\n`, "utf-8");
3092
+ }
3093
+ /**
3094
+ * Bring an agent's `.memory/` up to `CURRENT_MEMORY_VERSION` by applying the
3095
+ * ordered migrations. Runs on session start. No-op when there's no `.memory/`
3096
+ * yet or it's already current. Migrations must be idempotent, so a lost/unwritten
3097
+ * version marker (the file isn't committed by the harness) only costs a repeated
3098
+ * no-op, never corruption. Returns the `{ from, to }` actually applied, or
3099
+ * `null` when nothing ran.
3100
+ */
3101
+ async function migrateMemory({ cwd }) {
3102
+ const from = await readMemoryVersion(cwd);
3103
+ if (from === null || from >= 2) return null;
3104
+ let version = from;
3105
+ while (version < 2) {
3106
+ const migration = MEMORY_MIGRATIONS.find((m) => m.from === version);
3107
+ if (!migration) break;
3108
+ await migration.apply({ cwd });
3109
+ version = migration.to;
3110
+ }
3111
+ await writeMemoryVersion(cwd, version);
3112
+ return {
3113
+ from,
3114
+ to: version
3115
+ };
3116
+ }
2438
3117
  //#endregion
2439
3118
  //#region src/extensions/memory.ts
2440
3119
  const log$6 = logger.child({ module: "memory-extension" });
2441
3120
  /**
2442
3121
  * The standing instructions for the memory system. Always injected (even with
2443
- * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
3122
+ * no `MEMORY.md`) so the agent knows it can persist notes and how. `users/` is
2444
3123
  * described by the platform memory extension, which is the only thing that can
2445
3124
  * scope it to a person — here we just point at it.
2446
3125
  */
2447
3126
  function memoryInstructions(cwd) {
2448
3127
  return `## Memory across conversations
2449
3128
 
2450
- Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo. The harness builds and injects an **index** of these files (paths + one-line descriptions) into your system prompt every turn; **bodies are NOT auto-loaded** — when an index entry looks relevant, use your \`read\` tool to load that specific file.
3129
+ Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo, one fact per file. You maintain a hand-written index of them at \`${cwd}/.memory/MEMORY.md\`, and the harness injects that index into your system prompt every turn. **Bodies are NOT auto-loaded** — when an index line looks relevant, use your \`read\` tool to load that specific file.
2451
3130
 
2452
3131
  Memory records **what happened**: facts you learned, events, investigation findings, project and system details worth carrying forward. It is NOT where behavior goes. A standing rule about how you should act — a "from now on, always/never …", a tone or format preference, a workflow convention a user wants you to follow — belongs in \`soul.md\` (see the Persona / Standing instructions section), not here. When a note is really an instruction about your behavior, write it to \`soul.md\`; when it is a fact or a record of something that occurred, write it here.
2453
3132
 
2454
- Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are shown separately, scoped to whoever you're talking to.) Each file's frontmatter declares \`name\` and \`description\` (the description is what shows up in the index, so make it a one-line behavior-triggering hook). Commit and push after writing to persist it.`;
3133
+ You own \`MEMORY.md\`. When you learn something durable, write the fact to its own \`.md\` file and add a one-line pointer to \`MEMORY.md\` in the form \`- [Title](relative/path.md) — one-line hook\`, where the hook is what tells future-you when to open the file. \`MEMORY.md\` is an *index*, never a store — put the actual content in the topic file and only a pointer line in \`MEMORY.md\`; do not inline a fact's body into the index even when it seems cheaper. Start each topic file with \`name:\`/\`description:\` frontmatter (the \`description\` is the one-line hook) so the index can be re-seeded, re-derived, or linted from the files themselves. When a fact changes, edit both the file and its line; when it stops being true, delete the file and its line. Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are indexed for you separately, scoped to whoever you're talking to don't put people's private notes in the shared \`MEMORY.md\`.)
3134
+
3135
+ Keep \`MEMORY.md\` lean. It's re-injected on *every* turn, so a small, high-signal index is worth far more than an exhaustive one — curate it like a tightly-edited table of contents, not a log:
3136
+ - Be selective. Only record something durable that will matter in a *future* conversation. Don't record what only matters right now, what you can re-derive on demand, or what's already obvious from the repo.
3137
+ - Consolidate before you create. Before adding a line, scan \`MEMORY.md\` for one that already covers the topic; if it exists, \`read\` that file and rewrite it with the new facts merged in rather than adding a near-duplicate. One fact per file, but don't fragment a topic across many thin files.
3138
+ - Prune as you go. Delete lines (and their files) that are wrong, stale, or superseded. The index header reports its size — when it's flagged over budget, consolidate and delete before adding anything new.
3139
+ - Write dates absolute, not relative. Resolve "last week" / "yesterday" to a concrete date when you record it (e.g. "on 7/3 Dhruv told me to …"), so the note still reads correctly in a future conversation.
3140
+
3141
+ Commit and push after editing \`.memory/\` to persist it.`;
2455
3142
  }
2456
3143
  function composeBlock$1({ cwd, index }) {
2457
3144
  const instructions = memoryInstructions(cwd);
2458
3145
  if (!index || index.length === 0) return instructions;
2459
- return `${instructions}\n\n## Memory index\n\n${index}`;
3146
+ const header = `## Memory index (${indexSizeNote(index)})`;
3147
+ const warning = indexBudgetWarning(index);
3148
+ const rendered = enforceMemoryIndexHardBudget(index);
3149
+ return `${instructions}\n\n${warning ? `${header}\n\n${warning}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
2460
3150
  }
2461
3151
  const memoryExtension = (pi) => {
2462
3152
  let cachedBlock = null;
2463
3153
  pi.on("session_start", async (_event, ctx) => {
2464
3154
  try {
2465
- const index = await buildMemoryIndex({
2466
- cwd: ctx.cwd,
2467
- scope: { kind: "shared" }
2468
- });
3155
+ const migrated = await migrateMemory({ cwd: ctx.cwd });
3156
+ if (migrated !== null) log$6.info({
3157
+ event: "memory_migrated",
3158
+ from: migrated.from,
3159
+ to: migrated.to
3160
+ }, "migrated memory layout to current version");
3161
+ } catch (err) {
3162
+ log$6.warn({
3163
+ err,
3164
+ event: "memory_migration_failed"
3165
+ }, "memory migration failed; continuing with existing index");
3166
+ }
3167
+ try {
3168
+ const index = await readMemoryIndexFile({ cwd: ctx.cwd });
2469
3169
  cachedBlock = composeBlock$1({
2470
3170
  cwd: ctx.cwd,
2471
3171
  index
@@ -2474,7 +3174,7 @@ const memoryExtension = (pi) => {
2474
3174
  log$6.warn({
2475
3175
  err,
2476
3176
  event: "memory_index_failed"
2477
- }, "memory index build failed; injecting instructions only");
3177
+ }, "memory index read failed; injecting instructions only");
2478
3178
  cachedBlock = memoryInstructions(ctx.cwd);
2479
3179
  }
2480
3180
  });
@@ -2533,9 +3233,12 @@ function slugifyName(name) {
2533
3233
  function composeBlock({ index, user }) {
2534
3234
  const instructions = `## Current user memory
2535
3235
 
2536
- Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory.`;
3236
+ Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory. Keep it lean and be selective — this is re-injected every turn; consolidate related facts into one file and delete what's stale rather than piling on near-duplicates.`;
2537
3237
  if (!index || index.length === 0) return instructions;
2538
- return `${instructions}\n\n${index}`;
3238
+ const warning = indexBudgetWarning(index);
3239
+ const header = `Memory index (${indexSizeNote(index)}):`;
3240
+ const rendered = enforceMemoryIndexHardBudget(index);
3241
+ return `${instructions}\n\n${warning ? `${warning}\n\n${header}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
2539
3242
  }
2540
3243
  /**
2541
3244
  * Build the platform memory extension. `channelContext` is the per-turn ref
@@ -2556,10 +3259,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2556
3259
  cachedBlock = composeBlock({
2557
3260
  index: await buildMemoryIndex({
2558
3261
  cwd: ctx.cwd,
2559
- scope: {
2560
- kind: "user",
2561
- userId: user.id
2562
- }
3262
+ userId: user.id
2563
3263
  }),
2564
3264
  user
2565
3265
  });
@@ -2736,7 +3436,7 @@ function soulSection(cwd, soul) {
2736
3436
 
2737
3437
  **\`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
3438
 
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. Edit it (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
3439
+ 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
3440
 
2741
3441
  ${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
2742
3442
  }
@@ -2758,9 +3458,19 @@ const log$2 = logger.child({ module: "subagent-ext" });
2758
3458
  const MAX_TASKS = 8;
2759
3459
  const TaskItem = Type.Object({
2760
3460
  task: Type.String({ description: "The task to delegate to a subagent run." }),
3461
+ title: Type.String({
3462
+ 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…\".",
3463
+ maxLength: 120
3464
+ }),
2761
3465
  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 (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." }))
3466
+ 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." })),
3467
+ timeoutMinutes: Type.Optional(Type.Integer({
3468
+ 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.",
3469
+ minimum: 1,
3470
+ maximum: 360
3471
+ }))
2763
3472
  });
3473
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2764
3474
  const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2765
3475
  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
3476
  minItems: 1,
@@ -2775,7 +3485,9 @@ function buildTool(messageId) {
2775
3485
  "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2776
3486
  "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
3487
  "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 run on your own model."
3488
+ "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.",
3489
+ "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.",
3490
+ "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
3491
  ].join(" "),
2780
3492
  promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2781
3493
  parameters: SubagentParams,
@@ -2791,25 +3503,37 @@ function buildTool(messageId) {
2791
3503
  };
2792
3504
  const spawnTasks = tasks.map((t) => ({
2793
3505
  task: t.task,
3506
+ title: t.title ?? null,
2794
3507
  persona: t.persona ?? null,
2795
- model: t.model ?? null
3508
+ model: t.model ?? null,
3509
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2796
3510
  }));
2797
3511
  try {
2798
- const { taskIds } = await postSubagentSpawn({
3512
+ const spawned = await postSubagentSpawn({
2799
3513
  messageId,
2800
3514
  tasks: spawnTasks
2801
3515
  });
3516
+ const { taskIds } = spawned;
2802
3517
  log$2.info({
2803
3518
  event: "subagent_spawned",
2804
3519
  count: taskIds.length
2805
3520
  }, "subagent tasks queued");
2806
- const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.task ?? ""}`).join("\n");
3521
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
3522
+ const lines = taskIds.map((id, i) => {
3523
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
3524
+ const conv = convByTask.get(id);
3525
+ return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
3526
+ }).join("\n");
3527
+ 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
3528
  return {
2808
3529
  content: [{
2809
3530
  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}`
3531
+ 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
3532
  }],
2812
- details: { taskIds }
3533
+ details: {
3534
+ taskIds,
3535
+ tasks: spawned.tasks
3536
+ }
2813
3537
  };
2814
3538
  } catch (err) {
2815
3539
  const message = err instanceof Error ? err.message : String(err);
@@ -2830,31 +3554,37 @@ function buildTool(messageId) {
2830
3554
  };
2831
3555
  }
2832
3556
  /**
2833
- * Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
2834
3557
  * The factory takes the session's channel context to resolve the originating
2835
3558
  * messageId — the api links each spawned run to the conversation that message
2836
3559
  * belongs to and rewakes it on completion (nothing about the parent is piped
2837
- * from the sandbox beyond that id).
3560
+ * from the sandbox beyond that id). The tool is registered unconditionally at
3561
+ * session_start.
2838
3562
  */
2839
3563
  function createSubagentExtension({ channelContext }) {
2840
3564
  return (pi) => {
2841
3565
  const messageId = extractMessageId(channelContext);
2842
- startFeatureFlagPoller();
2843
- pi.on("session_start", async () => {
2844
- if (getPolledFlag("subagent") === true) {
2845
- pi.registerTool(buildTool(messageId));
2846
- log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
2847
- }
3566
+ let registered = false;
3567
+ const registerOnce = () => {
3568
+ if (registered) return;
3569
+ registered = true;
3570
+ pi.registerTool(buildTool(messageId));
3571
+ log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
3572
+ };
3573
+ pi.on("session_start", () => {
3574
+ registerOnce();
2848
3575
  });
2849
3576
  };
2850
3577
  }
2851
3578
  //#endregion
2852
3579
  //#region src/extensions/tool-call-env.ts
2853
3580
  const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
3581
+ function shellQuoteValue(value) {
3582
+ return quote([value]);
3583
+ }
2854
3584
  function withToolCallId({ command, toolCallId }) {
2855
- return `export ${TOOL_CALL_ID_VAR}=${toolCallId}; ${command}`;
3585
+ return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
2856
3586
  }
2857
- const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'[^']*'|[^;\\s]*)\\s*;\\s*`);
3587
+ const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
2858
3588
  function stripPlatformExportsForDisplay(command) {
2859
3589
  let c = command;
2860
3590
  let m;
@@ -3119,8 +3849,14 @@ const toolCallSummaryExtension = (pi) => {
3119
3849
  * from the origin messageId in its channel context (`resolveConversationFromApi`)
3120
3850
  * — and every run of the same conversation resolves to the same id, keeping the
3121
3851
  * shared map correctly scoped across turns. `bg_*`, the completion wake, and the
3122
- * next-session injection all filter to the resolved conversationan agent
3123
- * never sees or is woken by a task from a different chat. Only the output log
3852
+ * next-session injection all filter by a **scope key**the resolved
3853
+ * conversation id, or, when a session's conversation is unresolvable (a bare
3854
+ * CLI session, or a run whose channel-context ref carries no messageId), a
3855
+ * sentinel unique to that one session instance. Comparing on the raw
3856
+ * `conversationId` would bucket every unresolvable session together under
3857
+ * `null` and leak one's completion wake / status / next-session injection into
3858
+ * another; the sentinel keeps each isolated so an agent never sees or is woken
3859
+ * by a task from a different chat. Only the output log
3124
3860
  * spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
3125
3861
  * in memory; exit code and run state live on the in-memory task.
3126
3862
  *
@@ -3144,6 +3880,7 @@ const WATCHDOG_INTERVAL_MS = 3e4;
3144
3880
  const KEEPALIVE_EVERY_MS = 6e4;
3145
3881
  const KEEPALIVE_MAX_MS = 3600 * 1e3;
3146
3882
  const STALL_HINT_AFTER_MS = 120 * 1e3;
3883
+ const PUBLISH_DEBOUNCE_MS = 300;
3147
3884
  const MAX_LOG_BYTES = 100 * 1024 * 1024;
3148
3885
  const DEFAULT_TAIL_LINES = 30;
3149
3886
  const TAIL_READ_BYTES = 64 * 1024;
@@ -3151,11 +3888,12 @@ function taskLabel(meta) {
3151
3888
  return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
3152
3889
  }
3153
3890
  let taskCounter = 0;
3891
+ let sessionScopeCounter = 0;
3154
3892
  const tasks = /* @__PURE__ */ new Map();
3155
3893
  let watchdogInterval = null;
3156
3894
  let lastKeepaliveAt = 0;
3157
- function sameConversation(meta, conversationId) {
3158
- return meta.conversationId === conversationId;
3895
+ function sameScope(meta, scopeKey) {
3896
+ return meta.scopeKey === scopeKey;
3159
3897
  }
3160
3898
  function logPath(id) {
3161
3899
  return join(tasksDir(), `${id}.log`);
@@ -3268,6 +4006,10 @@ function createBackgroundTasksExtension({ channelContext }) {
3268
4006
  return id;
3269
4007
  });
3270
4008
  }
4009
+ const unresolvedScopeSentinel = `unresolved:${process.pid.toString(36)}:${(sessionScopeCounter += 1).toString(36)}`;
4010
+ function scopeKey() {
4011
+ return conversationId ?? unresolvedScopeSentinel;
4012
+ }
3271
4013
  let agentActive = false;
3272
4014
  pi.on("agent_start", async () => {
3273
4015
  agentActive = true;
@@ -3301,7 +4043,7 @@ This is a background-task completion, not a message from the user. If it needs n
3301
4043
  }
3302
4044
  async function notifyCompletion(meta) {
3303
4045
  if (meta.notified) return;
3304
- if (agentActive && sameConversation(meta, conversationId)) {
4046
+ if (agentActive && sameScope(meta, scopeKey())) {
3305
4047
  meta.notified = true;
3306
4048
  pi.sendMessage(await taskDoneMessage(meta), {
3307
4049
  triggerTurn: true,
@@ -3332,6 +4074,80 @@ This is a background-task completion, not a message from the user. If it needs n
3332
4074
  });
3333
4075
  } else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
3334
4076
  }
4077
+ const SNAPSHOT_TAIL_LINES = 40;
4078
+ let lastPublishedSignature = null;
4079
+ let publishInFlight = null;
4080
+ let publishQueued = false;
4081
+ function snapshotSignature(snapshot) {
4082
+ return JSON.stringify(snapshot.map((t) => ({
4083
+ id: t.id,
4084
+ state: t.state,
4085
+ exitCode: t.exitCode,
4086
+ killedReason: t.killedReason,
4087
+ outputTail: t.outputTail
4088
+ })));
4089
+ }
4090
+ async function doPublishSnapshot() {
4091
+ if (!messageId) return;
4092
+ const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
4093
+ try {
4094
+ const snapshot = await Promise.all(mine.map(async (t) => ({
4095
+ id: t.id,
4096
+ command: t.command,
4097
+ description: t.description,
4098
+ startedAt: t.startedAt,
4099
+ state: t.running ? "running" : "finished",
4100
+ exitCode: t.exitCode,
4101
+ killedReason: t.killedReason,
4102
+ outputTail: await tailLog(t.id, SNAPSHOT_TAIL_LINES)
4103
+ })));
4104
+ const signature = snapshotSignature(snapshot);
4105
+ if (signature === lastPublishedSignature) return;
4106
+ lastPublishedSignature = signature;
4107
+ postBackgroundTasksSnapshot({
4108
+ messageId,
4109
+ tasks: snapshot
4110
+ });
4111
+ } catch (err) {
4112
+ log.debug({
4113
+ err,
4114
+ event: "bg_tasks_snapshot_build_failed"
4115
+ }, "building bg-tasks snapshot failed");
4116
+ }
4117
+ }
4118
+ async function publishSnapshotNow() {
4119
+ if (publishInFlight) {
4120
+ publishQueued = true;
4121
+ return;
4122
+ }
4123
+ publishInFlight = (async () => {
4124
+ try {
4125
+ do {
4126
+ publishQueued = false;
4127
+ await doPublishSnapshot();
4128
+ } while (publishQueued);
4129
+ } finally {
4130
+ publishInFlight = null;
4131
+ }
4132
+ })();
4133
+ await publishInFlight;
4134
+ }
4135
+ let publishTimer = null;
4136
+ function schedulePublishSnapshot() {
4137
+ if (publishTimer) return;
4138
+ publishTimer = setTimeout(() => {
4139
+ publishTimer = null;
4140
+ publishSnapshotNow();
4141
+ }, PUBLISH_DEBOUNCE_MS);
4142
+ publishTimer.unref?.();
4143
+ }
4144
+ async function flushPublishSnapshot() {
4145
+ if (publishTimer) {
4146
+ clearTimeout(publishTimer);
4147
+ publishTimer = null;
4148
+ }
4149
+ await publishSnapshotNow();
4150
+ }
3335
4151
  async function launchTask({ command, description, cwd }) {
3336
4152
  taskCounter += 1;
3337
4153
  const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
@@ -3358,6 +4174,7 @@ This is a background-task completion, not a message from the user. If it needs n
3358
4174
  logBytes: 0,
3359
4175
  lastOutputAt: startedAt,
3360
4176
  conversationId,
4177
+ scopeKey: scopeKey(),
3361
4178
  messageId,
3362
4179
  description,
3363
4180
  notified: false,
@@ -3393,6 +4210,7 @@ This is a background-task completion, not a message from the user. If it needs n
3393
4210
  taskId: id,
3394
4211
  exitCode: meta.exitCode
3395
4212
  }, "bg task finished");
4213
+ schedulePublishSnapshot();
3396
4214
  await notifyCompletion(meta);
3397
4215
  });
3398
4216
  ensureWatchdog();
@@ -3400,19 +4218,20 @@ This is a background-task completion, not a message from the user. If it needs n
3400
4218
  taskId: id,
3401
4219
  conversationId
3402
4220
  }, "bg task started");
4221
+ schedulePublishSnapshot();
3403
4222
  return meta;
3404
4223
  }
3405
4224
  function knownTaskIds() {
3406
- return [...tasks.values()].filter((t) => sameConversation(t, conversationId)).map((t) => t.id).join(", ") || "(none)";
4225
+ return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
3407
4226
  }
3408
4227
  pi.on("session_start", async () => {
3409
4228
  if (tasks.size === 0) return;
3410
4229
  await ensureConversationId();
3411
- for (const [id, meta] of tasks) if (sameConversation(meta, conversationId) && !meta.running && meta.notified) {
4230
+ for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
3412
4231
  tasks.delete(id);
3413
4232
  await unlink(logPath(id)).catch(() => {});
3414
4233
  }
3415
- const unnotified = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && !t.notified && !t.running);
4234
+ const unnotified = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && !t.notified && !t.running);
3416
4235
  for (const meta of unnotified) {
3417
4236
  meta.notified = true;
3418
4237
  pi.sendMessage(await taskDoneMessage(meta));
@@ -3421,7 +4240,9 @@ This is a background-task completion, not a message from the user. If it needs n
3421
4240
  conversationId,
3422
4241
  count: unnotified.length
3423
4242
  }, "injected completed bg tasks at session_start");
3424
- if ([...tasks.values()].some((t) => sameConversation(t, conversationId) && t.running)) ensureWatchdog();
4243
+ if ([...tasks.values()].some((t) => sameScope(t, scopeKey()) && t.running)) ensureWatchdog();
4244
+ lastPublishedSignature = null;
4245
+ await flushPublishSnapshot();
3425
4246
  });
3426
4247
  function err(text) {
3427
4248
  return {
@@ -3438,11 +4259,11 @@ This is a background-task completion, not a message from the user. If it needs n
3438
4259
  }
3439
4260
  function resolveTask(taskId) {
3440
4261
  const exact = tasks.get(taskId);
3441
- if (exact && sameConversation(exact, conversationId)) return {
4262
+ if (exact && sameScope(exact, scopeKey())) return {
3442
4263
  error: null,
3443
4264
  meta: exact
3444
4265
  };
3445
- const matches = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && t.id.startsWith(taskId));
4266
+ const matches = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && t.id.startsWith(taskId));
3446
4267
  if (matches.length === 1) return {
3447
4268
  error: null,
3448
4269
  meta: matches[0]
@@ -3451,7 +4272,7 @@ This is a background-task completion, not a message from the user. If it needs n
3451
4272
  return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
3452
4273
  }
3453
4274
  function listTasks() {
3454
- const mine = [...tasks.values()].filter((t) => sameConversation(t, conversationId));
4275
+ const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
3455
4276
  if (mine.length === 0) return "No background tasks.";
3456
4277
  return mine.map((t) => {
3457
4278
  const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
@@ -3601,6 +4422,7 @@ const all = [
3601
4422
  localToolsExtension,
3602
4423
  toolCallEnvExtension,
3603
4424
  bashDefaultTimeoutExtension,
4425
+ diskGuardExtension,
3604
4426
  toolCallSummaryExtension
3605
4427
  ];
3606
4428
  /**
@@ -3619,7 +4441,8 @@ function platformExtensions({ sessionId, channelContext }) {
3619
4441
  selfTraceExtension,
3620
4442
  createBackgroundTasksExtension({ channelContext }),
3621
4443
  createSubagentExtension({ channelContext }),
3622
- createContextManagementExtension()
4444
+ createContextManagementExtension(),
4445
+ resourcePressureWarningExtension
3623
4446
  ];
3624
4447
  }
3625
4448
  //#endregion