@skydiveai/pi-extensions 0.1.0-beta.197 → 0.1.0-beta.1973

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 +1661 -504
  2. package/package.json +2 -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,6 +19,9 @@ 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";
24
27
  import { quote } from "shell-quote";
@@ -256,7 +259,7 @@ function createHealthHandler({ metadata }) {
256
259
  * read on the hot path before every LLM call), it falls back to the default
257
260
  * for that knob and logs once.
258
261
  */
259
- const log$13 = logger.child({ module: "context-management-config" });
262
+ const log$15 = logger.child({ module: "context-management-config" });
260
263
  const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
261
264
  enabled: false,
262
265
  perResultMaxBytes: 16 * 1024,
@@ -304,7 +307,7 @@ function resolveContextManagementConfig(env = process.env) {
304
307
  maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
305
308
  });
306
309
  if (!parsed.success) {
307
- log$13.warn({
310
+ log$15.warn({
308
311
  event: "context_management_config_invalid",
309
312
  err: parsed.error
310
313
  }, "falling back to default context-management config");
@@ -415,6 +418,275 @@ function installIterationCap({ session, log }, configOverride = null) {
415
418
  */
416
419
  const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task is done, if this changes what you can do for the user, record it in `soul.md` so it carries into future conversations rather than being rediscovered from scratch (then commit and push).";
417
420
  //#endregion
421
+ //#region src/extensions/tool-call-summary.ts
422
+ const log$14 = logger.child({ module: "tool-call-summary-extension" });
423
+ /**
424
+ * The injected parameter name: a namespaced sentinel, so it can never collide
425
+ * with a real tool argument and is unmistakable in transcripts and logs. The
426
+ * frontend renderer (ANY-2723) duplicates this literal — keep the two in sync.
427
+ */
428
+ const TOOL_CALL_SUMMARY_FIELD = "__skydive_summary__";
429
+ /** JSON Schema fragment for the injected parameter. */
430
+ const SUMMARY_PROPERTY = {
431
+ type: "string",
432
+ description: "Required for every tool call. A concise, specific summary (max ~8 words) of what THIS call does and why, written for a person watching the conversation, e.g. \"Searching feedback for billing complaints\" or \"Reading the auth middleware\". Address the user directly in second person: the summary is read by the user, so refer to their things as \"your\", never in third person — \"Reading your emails\", not \"Reading his emails\". Always use the present progressive tense, since it is shown while the call runs: \"Updating your Slack\", never \"Updated your Slack\". Make each summary distinct from your other tool calls; never reuse a generic label like \"Search query\" or \"Running command\"."
433
+ };
434
+ const jsonSchemaObjectSchema = z.object({
435
+ type: z.unknown().optional(),
436
+ properties: z.record(z.string(), z.unknown()).optional(),
437
+ required: z.array(z.string()).optional(),
438
+ additionalProperties: z.unknown().optional()
439
+ }).passthrough();
440
+ const toolEntrySchema = z.object({
441
+ name: z.string().optional(),
442
+ input_schema: jsonSchemaObjectSchema.optional(),
443
+ parameters: jsonSchemaObjectSchema.optional(),
444
+ function: z.object({
445
+ name: z.string().optional(),
446
+ parameters: jsonSchemaObjectSchema.optional()
447
+ }).passthrough().optional()
448
+ }).passthrough();
449
+ const payloadWithToolsSchema = z.object({ tools: z.array(z.unknown()) }).passthrough();
450
+ /**
451
+ * Add the summary property to one JSON Schema object. Returns the augmented
452
+ * copy, or `null` when the tool should be left untouched: a strict schema
453
+ * (`additionalProperties: false`) whose validation would reject the extra
454
+ * field, or one that already declares a `__skydive_summary__` property of its own.
455
+ */
456
+ function augmentSchema(schema) {
457
+ if (schema.additionalProperties === false) return null;
458
+ const properties = schema.properties ?? {};
459
+ if ("__skydive_summary__" in properties) return null;
460
+ const required = schema.required ?? [];
461
+ return {
462
+ ...schema,
463
+ type: schema.type ?? "object",
464
+ properties: {
465
+ [TOOL_CALL_SUMMARY_FIELD]: SUMMARY_PROPERTY,
466
+ ...properties
467
+ },
468
+ required: required.includes("__skydive_summary__") ? required : [...required, TOOL_CALL_SUMMARY_FIELD]
469
+ };
470
+ }
471
+ /**
472
+ * Augment a single tool entry, dispatching on which provider shape it is.
473
+ * Returns the (possibly rebuilt) entry and whether anything changed. Skipped
474
+ * tools — wrong shape, strict, or name in `strictToolNames` — return unchanged.
475
+ */
476
+ function augmentToolEntry(entry, strictToolNames) {
477
+ const parsed = toolEntrySchema.safeParse(entry);
478
+ if (!parsed.success) return {
479
+ entry,
480
+ changed: false
481
+ };
482
+ const tool = parsed.data;
483
+ const name = tool.name ?? tool.function?.name ?? null;
484
+ if (name !== null && strictToolNames.has(name)) return {
485
+ entry,
486
+ changed: false
487
+ };
488
+ if (tool.input_schema) {
489
+ const augmented = augmentSchema(tool.input_schema);
490
+ if (!augmented) return {
491
+ entry,
492
+ changed: false
493
+ };
494
+ return {
495
+ entry: {
496
+ ...tool,
497
+ input_schema: augmented
498
+ },
499
+ changed: true
500
+ };
501
+ }
502
+ if (tool.parameters) {
503
+ const augmented = augmentSchema(tool.parameters);
504
+ if (!augmented) return {
505
+ entry,
506
+ changed: false
507
+ };
508
+ return {
509
+ entry: {
510
+ ...tool,
511
+ parameters: augmented
512
+ },
513
+ changed: true
514
+ };
515
+ }
516
+ if (tool.function?.parameters) {
517
+ const augmented = augmentSchema(tool.function.parameters);
518
+ if (!augmented) return {
519
+ entry,
520
+ changed: false
521
+ };
522
+ return {
523
+ entry: {
524
+ ...tool,
525
+ function: {
526
+ ...tool.function,
527
+ parameters: augmented
528
+ }
529
+ },
530
+ changed: true
531
+ };
532
+ }
533
+ return {
534
+ entry,
535
+ changed: false
536
+ };
537
+ }
538
+ /**
539
+ * Inject the summary field into every eligible tool in a provider payload.
540
+ * Returns a new payload when at least one tool was augmented, or `undefined`
541
+ * to signal "no change" (which keeps the original payload, per the
542
+ * `before_provider_request` contract).
543
+ *
544
+ * @param payload The outgoing provider payload (shape varies by provider).
545
+ * @param strictToolNames Names of tools whose registered schema is strict and
546
+ * must be skipped to avoid validation errors.
547
+ */
548
+ function injectToolCallSummary(payload, strictToolNames) {
549
+ const parsed = payloadWithToolsSchema.safeParse(payload);
550
+ if (!parsed.success || parsed.data.tools.length === 0) return void 0;
551
+ let changed = false;
552
+ const tools = parsed.data.tools.map((entry) => {
553
+ const result = augmentToolEntry(entry, strictToolNames);
554
+ if (result.changed) changed = true;
555
+ return result.entry;
556
+ });
557
+ if (!changed) return void 0;
558
+ return {
559
+ ...parsed.data,
560
+ tools
561
+ };
562
+ }
563
+ /**
564
+ * Names of registered tools whose schema sets `additionalProperties: false`.
565
+ * Pi validates the model's tool args against this registered schema, so the
566
+ * injected field would make a strict tool's call fail validation — skip them.
567
+ */
568
+ function getStrictToolNames(pi) {
569
+ const names = /* @__PURE__ */ new Set();
570
+ for (const tool of pi.getAllTools()) {
571
+ const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
572
+ if (parsed.success && parsed.data.additionalProperties === false) names.add(tool.name);
573
+ }
574
+ return names;
575
+ }
576
+ /**
577
+ * Whether a tool's own schema declares a `__skydive_summary__` property. We
578
+ * never inject into such a tool, so any value it carries is a real argument
579
+ * and must be left alone.
580
+ */
581
+ function schemaDeclaresSummary(parameters) {
582
+ const parsed = jsonSchemaObjectSchema.safeParse(parameters);
583
+ return parsed.success && parsed.data.properties != null && "__skydive_summary__" in parsed.data.properties;
584
+ }
585
+ function toolDeclaresSummaryParam(pi, toolName) {
586
+ const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
587
+ if (!tool) return false;
588
+ return schemaDeclaresSummary(tool.parameters);
589
+ }
590
+ /** A copy of `args` without the injected summary. Never mutates its input. */
591
+ function withoutInjectedSummary(args) {
592
+ if (args == null || typeof args !== "object" || Array.isArray(args)) return args;
593
+ if (!("__skydive_summary__" in args)) return args;
594
+ const { [TOOL_CALL_SUMMARY_FIELD]: _summary, ...rest } = args;
595
+ return rest;
596
+ }
597
+ /**
598
+ * Register a tool so the injected summary is removed *before* pi validates
599
+ * the model's arguments against the tool's schema.
600
+ *
601
+ * Needed because the `tool_call` strip below runs too late. pi-agent-core's
602
+ * `prepareToolCall` goes: `tool.prepareArguments` → `validateToolArguments` →
603
+ * `beforeToolCall` (which is what dispatches `tool_call`). A tool whose schema
604
+ * sets `additionalProperties: false` therefore rejects the summary and errors
605
+ * the call — client-side, before any request reaches the server — while the
606
+ * strip meant to prevent exactly that sits one step further down.
607
+ *
608
+ * `augmentSchema` already skips strict tools, but that only controls what the
609
+ * model is *told*. It still emits the field on those tools, because every
610
+ * other tool in the list declares it as required for every call. So a server
611
+ * can connect, bind its tools, present a healthy inventory, and have every
612
+ * call fail — and it reads as the server's fault when it is ours.
613
+ *
614
+ * Apply to tools registered from schemas we do not author: MCP servers and
615
+ * local `tools/*.ts`. Tools built here with `Type.Object(...)` do not need it
616
+ * (TypeBox emits no `additionalProperties`, so the field validates fine and
617
+ * the `tool_call` strip removes it in time).
618
+ *
619
+ * Fails open, like the rest of this module: if the strip throws, the original
620
+ * arguments are used rather than failing the call.
621
+ */
622
+ function withSummaryStrippedBeforeValidation(tool) {
623
+ if (schemaDeclaresSummary(tool.parameters)) return tool;
624
+ const toolPrepare = tool.prepareArguments;
625
+ const prepareArguments = ((args) => {
626
+ let stripped = args;
627
+ try {
628
+ stripped = withoutInjectedSummary(args);
629
+ } catch (err) {
630
+ log$14.error({
631
+ err,
632
+ event: "tool_call_summary_prepare_strip_failed",
633
+ toolName: tool.name
634
+ }, "tool_call_summary pre-validation strip failed; leaving arguments untouched");
635
+ }
636
+ return toolPrepare ? toolPrepare(stripped) : stripped;
637
+ });
638
+ return {
639
+ ...tool,
640
+ prepareArguments
641
+ };
642
+ }
643
+ /**
644
+ * Remove the injected summary from a tool's execution input. No-op when the
645
+ * field is absent, or when the tool genuinely declares a `__skydive_summary__`
646
+ * parameter of its own (which we never inject into, so its value is real).
647
+ * Mutates `input` in place, matching the `tool_call` contract.
648
+ *
649
+ * Fails open: this runs on the critical path of tool execution, and the
650
+ * `getAllTools()` lookup can throw. On any error we leave `input` untouched
651
+ * (the sentinel may pass through to the tool, but a bug here can never break
652
+ * tool execution).
653
+ */
654
+ function stripInjectedSummary(pi, toolName, input) {
655
+ try {
656
+ if (!("__skydive_summary__" in input)) return;
657
+ if (toolDeclaresSummaryParam(pi, toolName)) return;
658
+ delete input[TOOL_CALL_SUMMARY_FIELD];
659
+ } catch (err) {
660
+ log$14.error({
661
+ err,
662
+ event: "tool_call_summary_strip_failed",
663
+ toolName
664
+ }, "tool_call_summary strip failed; leaving tool input untouched");
665
+ }
666
+ }
667
+ /**
668
+ * Compute the rewritten payload for a `before_provider_request` event, failing
669
+ * open: on any error the original payload is left untouched so a bug here can
670
+ * never break an LLM call.
671
+ */
672
+ function buildInjectedPayload(pi, payload) {
673
+ try {
674
+ return injectToolCallSummary(payload, getStrictToolNames(pi));
675
+ } catch (err) {
676
+ log$14.error({
677
+ err,
678
+ event: "tool_call_summary_injection_failed"
679
+ }, "tool_call_summary injection failed; passing payload through unchanged");
680
+ return;
681
+ }
682
+ }
683
+ const toolCallSummaryExtension = (pi) => {
684
+ pi.on("before_provider_request", (event) => buildInjectedPayload(pi, event.payload));
685
+ pi.on("tool_call", (event) => {
686
+ stripInjectedSummary(pi, event.toolName, event.input);
687
+ });
688
+ };
689
+ //#endregion
418
690
  //#region src/extensions/local-tools.ts
419
691
  /**
420
692
  * Local-tools adapter as a pi extension. Mirrors the mcp.ts hot-reload pattern
@@ -438,7 +710,7 @@ const CAPABILITY_SOUL_NUDGE = "New capability gained — once the current task i
438
710
  * or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
439
711
  * `tools/_example.ts` documents the shape without registering.
440
712
  */
441
- const log$12 = logger.child({ module: "local-tools-extension" });
713
+ const log$13 = logger.child({ module: "local-tools-extension" });
442
714
  const TOOLS_DIRNAME = "tools";
443
715
  const fileState = /* @__PURE__ */ new Map();
444
716
  let pendingLocalToolsUpdate = null;
@@ -565,7 +837,7 @@ async function reconcileLocalTools({ pi, dir }) {
565
837
  action = existing ? "refreshed" : "added";
566
838
  }
567
839
  for (const tool of tools) {
568
- pi.registerTool(withDefaultPromptSnippet(tool));
840
+ pi.registerTool(withSummaryStrippedBeforeValidation(withDefaultPromptSnippet(tool)));
569
841
  summary.totalTools++;
570
842
  }
571
843
  if (action === "added") summary.added.push(file);
@@ -582,7 +854,7 @@ async function reconcileAndQueue({ pi, dir, reason }) {
582
854
  dir
583
855
  });
584
856
  if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
585
- log$12.info({
857
+ log$13.info({
586
858
  event: "local_tools_reconcile",
587
859
  reason,
588
860
  total_tools: summary.totalTools,
@@ -604,7 +876,7 @@ const localToolsExtension = (pi) => {
604
876
  reason: "session_start"
605
877
  });
606
878
  } catch (err) {
607
- log$12.error({
879
+ log$13.error({
608
880
  err,
609
881
  event: "local_tools_reconcile_failed"
610
882
  }, "local tools reconcile failed");
@@ -616,7 +888,7 @@ const localToolsExtension = (pi) => {
616
888
  try {
617
889
  current = await listToolFiles(dir);
618
890
  } catch (err) {
619
- log$12.warn({
891
+ log$13.warn({
620
892
  err,
621
893
  event: "local_tools_listing_failed"
622
894
  }, "tools/ listing failed");
@@ -638,7 +910,7 @@ const localToolsExtension = (pi) => {
638
910
  reason: "auto_reload"
639
911
  });
640
912
  } catch (err) {
641
- log$12.error({
913
+ log$13.error({
642
914
  err,
643
915
  event: "local_tools_auto_reload_failed"
644
916
  }, "auto-reload after tools/ change failed");
@@ -692,6 +964,19 @@ const STDERR_BUFFER_BYTES = 4096;
692
964
  * indistinguishable from any other transport problem. Walk the cause
693
965
  * chain so the agent sees the real underlying error.
694
966
  */
967
+ /**
968
+ * True when an error from an http MCP transport (connect, listTools, or a tool
969
+ * call) is an authentication failure. With no authProvider configured the SDK
970
+ * surfaces a 401 as `StreamableHTTPError(401)`; older paths translate it to
971
+ * `UnauthorizedError`. A dead/expired OAuth token (the proxy can no longer
972
+ * mint one) shows up here on the NEXT request against a previously-connected
973
+ * client — not just at connect — so reconcile must re-classify such a failure
974
+ * as `pending_auth` instead of a generic `failed`, keeping the "waiting on
975
+ * auth" report consistent with `platform auth`.
976
+ */
977
+ function isUnauthorizedError(err) {
978
+ return err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401;
979
+ }
695
980
  function formatError(err) {
696
981
  if (!(err instanceof Error)) return String(err);
697
982
  const parts = [err.message];
@@ -713,12 +998,12 @@ async function connectHttp(_id, config, client) {
713
998
  stderr: null
714
999
  };
715
1000
  } catch (err) {
716
- if (err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401) return {
1001
+ if (isUnauthorizedError(err)) return {
717
1002
  status: "pending_auth",
718
1003
  client,
719
1004
  stderr: "",
720
1005
  stderrBuffer: null,
721
- cliHint: `platform auth mcp ${config.url}`
1006
+ cliHint: `platform auth mcp ${config.url} --service "<Product>"`
722
1007
  };
723
1008
  return {
724
1009
  status: "failed",
@@ -784,6 +1069,126 @@ async function connectClient(id, config, opts = {}) {
784
1069
  };
785
1070
  }
786
1071
  //#endregion
1072
+ //#region src/extensions/mcp/limits.ts
1073
+ /**
1074
+ * Tool-budget guardrails for MCP registration.
1075
+ *
1076
+ * Why this exists: the agent loop sends the *full* tool list — every tool's
1077
+ * name, description, and JSON-schema `parameters` — to the model on every
1078
+ * prompt. MCP servers add tools without bound: a handful of chatty servers (or
1079
+ * one server that exposes 100+ tools, or a few tools with enormous schemas) can
1080
+ * push the registered set past what the model can accept, and the request is
1081
+ * rejected before the turn even runs. Because `reconcile` re-registers from
1082
+ * `mcp.config.json` on *every* turn, an over-limit config bricks the harness on
1083
+ * a loop — the agent can't get a turn to run in order to edit the config back
1084
+ * down. Worse, the person can't tell *why*: tools just stop working.
1085
+ *
1086
+ * What actually overflows the request is *tokens*, not tool count — a few tools
1087
+ * with deeply-nested schemas and long descriptions cost more than a hundred
1088
+ * trivial ones. And how many tokens are safe depends on the *model*: a 200K
1089
+ * context window can afford far more tool surface than a 32K one. So the primary
1090
+ * limiter is a **token budget derived from the active model's context window**,
1091
+ * with a fixed tool-count cap as a coarse secondary guard (and the fallback
1092
+ * when the model — hence its window — isn't known at reconcile time).
1093
+ *
1094
+ * The fix is to make registration bounded and fail-soft. We register in
1095
+ * deterministic config order and stop before we blow the budget, recording how
1096
+ * much we dropped so the agent is *told* it hit the limit and which servers
1097
+ * were truncated. A config that would have bricked the harness now degrades to
1098
+ * "a bounded set of tools plus a loud warning", which the agent can act on by
1099
+ * pruning servers.
1100
+ *
1101
+ * Everything is env-overridable so the ceilings can be tuned per deployment
1102
+ * without a release, but ships with conservative defaults. A cap value of 0 (or
1103
+ * a non-finite / negative override) disables that cap — an explicit escape
1104
+ * hatch, not the default.
1105
+ */
1106
+ const env = process.env;
1107
+ /**
1108
+ * Fraction of the model's context window we're willing to spend on MCP tool
1109
+ * schemas. Tool definitions are sent on every prompt, so they permanently eat
1110
+ * into the window available for the conversation, but a generous tool budget is
1111
+ * worth more than a marginally larger conversation window given how the harness
1112
+ * is used. 0.35 of a 200K window is ~70K tokens of tool schema, comfortably
1113
+ * more than any sane MCP setup; of a 32K window it's ~11.2K, which still forces
1114
+ * truncation before a small model chokes.
1115
+ */
1116
+ const DEFAULT_MCP_TOOL_TOKEN_BUDGET_FRACTION = .35;
1117
+ /**
1118
+ * Floor for the token budget when the model's context window is unknown at
1119
+ * reconcile time (e.g. the model hasn't been resolved yet). Generous enough not
1120
+ * to truncate an ordinary tool set, low enough to still catch a runaway.
1121
+ */
1122
+ const DEFAULT_MCP_TOOL_TOKEN_BUDGET_FLOOR = 16e3;
1123
+ /**
1124
+ * Read a positive-integer cap from an env var, falling back to `fallback`.
1125
+ * A `0` override (or any non-finite / negative value) means "no cap" and is
1126
+ * returned as `Infinity`, so callers can compare against it directly.
1127
+ */
1128
+ function readCap(raw, fallback) {
1129
+ if (raw === void 0 || raw.trim() === "") return fallback;
1130
+ const parsed = Number(raw);
1131
+ if (!Number.isFinite(parsed) || parsed < 0) return Number.POSITIVE_INFINITY;
1132
+ if (parsed === 0) return Number.POSITIVE_INFINITY;
1133
+ return Math.floor(parsed);
1134
+ }
1135
+ /** Read a fraction in (0, 1] from an env var, falling back to `fallback`. */
1136
+ function readFraction(raw, fallback) {
1137
+ if (raw === void 0 || raw.trim() === "") return fallback;
1138
+ const parsed = Number(raw);
1139
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) return fallback;
1140
+ return parsed;
1141
+ }
1142
+ /** Read a non-negative integer from an env var, falling back to `fallback`. */
1143
+ function readNonNegativeInt(raw, fallback) {
1144
+ if (raw === void 0 || raw.trim() === "") return fallback;
1145
+ const parsed = Number(raw);
1146
+ if (!Number.isFinite(parsed) || parsed < 0) return fallback;
1147
+ return Math.floor(parsed);
1148
+ }
1149
+ /**
1150
+ * Resolve the active caps from the environment. Read once per reconcile so a
1151
+ * deployment can retune without a restart, cheap enough not to cache.
1152
+ */
1153
+ function resolveMcpToolLimits() {
1154
+ return {
1155
+ maxTotalTools: readCap(env["SKYDIVE_MCP_MAX_TOTAL_TOOLS"], 128),
1156
+ maxToolsPerServer: readCap(env["SKYDIVE_MCP_MAX_TOOLS_PER_SERVER"], 50),
1157
+ tokenBudgetFraction: readFraction(env["SKYDIVE_MCP_TOOL_TOKEN_BUDGET_FRACTION"], DEFAULT_MCP_TOOL_TOKEN_BUDGET_FRACTION),
1158
+ tokenBudgetFloor: readNonNegativeInt(env["SKYDIVE_MCP_TOOL_TOKEN_BUDGET_FLOOR"], DEFAULT_MCP_TOOL_TOKEN_BUDGET_FLOOR)
1159
+ };
1160
+ }
1161
+ /**
1162
+ * The token budget for MCP tool schemas, given the active model's context
1163
+ * window (or undefined when the model isn't known yet).
1164
+ *
1165
+ * With a known window we spend `tokenBudgetFraction` of it, but never less than
1166
+ * the floor — a tiny window shouldn't collapse the budget to near-zero and
1167
+ * strand every tool. With no window we fall back to the floor outright.
1168
+ */
1169
+ function resolveTokenBudget(limits, contextWindow) {
1170
+ if (contextWindow === void 0 || !Number.isFinite(contextWindow)) return limits.tokenBudgetFloor;
1171
+ return Math.max(limits.tokenBudgetFloor, Math.floor(contextWindow * limits.tokenBudgetFraction));
1172
+ }
1173
+ /**
1174
+ * Estimate the tokens an MCP tool's *definition* costs in the request. We
1175
+ * serialize what's actually sent to the model — the tool name, its description,
1176
+ * and its JSON-schema parameters — and apply pi's own ~chars/4 heuristic
1177
+ * (`estimateTokens` in the coding agent uses the same convention; there is no
1178
+ * per-provider tokenizer to lean on, and the provider's real usage is only
1179
+ * known after the response). Rough by design, but it tracks the true cost far
1180
+ * better than a flat per-tool count: a fat schema is charged for its fatness.
1181
+ */
1182
+ function estimateToolTokens(tool) {
1183
+ let chars = tool.name.length + (tool.description?.length ?? 0);
1184
+ if (tool.inputSchema !== void 0 && tool.inputSchema !== null) try {
1185
+ chars += JSON.stringify(tool.inputSchema).length;
1186
+ } catch {
1187
+ chars += 256;
1188
+ }
1189
+ return Math.ceil(chars / 4);
1190
+ }
1191
+ //#endregion
787
1192
  //#region src/extensions/mcp/mcp-config.ts
788
1193
  /**
789
1194
  * mcp.config.json schema + loader. Split out from the extension so it has
@@ -863,7 +1268,7 @@ async function loadMcpConfig(path) {
863
1268
  * Clients are keyed by JSON-stringified config and reused across
864
1269
  * reloads — only changed configs reconnect.
865
1270
  */
866
- const log$11 = logger.child({ module: "mcp-extension" });
1271
+ const log$12 = logger.child({ module: "mcp-extension" });
867
1272
  async function closeConnected(connected) {
868
1273
  try {
869
1274
  await connected.client.close();
@@ -953,7 +1358,7 @@ var McpExtension = class {
953
1358
  const parameters = Type.Unsafe(tool.inputSchema);
954
1359
  const description = tool.description?.trim() ?? "";
955
1360
  const promptSnippet = description.length > 0 ? description : `MCP tool from server "${serverId}".`;
956
- pi.registerTool({
1361
+ pi.registerTool(withSummaryStrippedBeforeValidation({
957
1362
  name,
958
1363
  label: `MCP: ${serverId}/${tool.name}`,
959
1364
  description,
@@ -987,22 +1392,29 @@ var McpExtension = class {
987
1392
  };
988
1393
  }
989
1394
  }
990
- });
1395
+ }));
991
1396
  this.registeredMcpToolNames.add(name);
992
1397
  }
993
- async reconcile({ pi, configPath, connectTimeoutMs }) {
1398
+ async reconcile({ pi, configPath, connectTimeoutMs, contextWindow }) {
994
1399
  let config;
995
1400
  try {
996
1401
  config = await loadMcpConfig(configPath);
997
1402
  } catch (err) {
998
1403
  throw new Error(`Failed to load MCP config: ${err instanceof Error ? err.message : String(err)}`);
999
1404
  }
1405
+ const limits = resolveMcpToolLimits();
1406
+ const tokenBudget = resolveTokenBudget(limits, contextWindow);
1000
1407
  const summary = {
1001
1408
  added: [],
1002
1409
  removed: [],
1003
1410
  refreshed: [],
1004
1411
  errors: [],
1005
1412
  totalTools: 0,
1413
+ droppedTools: 0,
1414
+ limits,
1415
+ tokenBudget,
1416
+ tokensUsed: 0,
1417
+ toolCounts: {},
1006
1418
  servers: {}
1007
1419
  };
1008
1420
  const desiredIds = new Set(Object.keys(config.servers));
@@ -1026,14 +1438,46 @@ var McpExtension = class {
1026
1438
  if (outcome.error) summary.errors.push(outcome.error);
1027
1439
  if (outcome.change === "added") summary.added.push(id);
1028
1440
  else if (outcome.change === "refreshed") summary.refreshed.push(id);
1029
- if (outcome.tools) for (const tool of outcome.tools.list) {
1030
- this.registerMcpTool({
1031
- pi,
1032
- serverId: id,
1033
- client: outcome.tools.client,
1034
- tool
1035
- });
1036
- summary.totalTools++;
1441
+ if (outcome.tools) {
1442
+ const advertised = outcome.tools.list.length;
1443
+ const perServerRoom = Math.min(advertised, limits.maxToolsPerServer);
1444
+ let registered = 0;
1445
+ let serverTokens = 0;
1446
+ let droppedReason = null;
1447
+ for (const tool of outcome.tools.list) {
1448
+ if (summary.totalTools >= limits.maxTotalTools) {
1449
+ droppedReason = "total";
1450
+ break;
1451
+ }
1452
+ if (registered >= perServerRoom) {
1453
+ droppedReason = "per_server";
1454
+ break;
1455
+ }
1456
+ const cost = estimateToolTokens(tool);
1457
+ if (summary.tokensUsed + cost > tokenBudget && summary.totalTools > 0) {
1458
+ droppedReason = "tokens";
1459
+ break;
1460
+ }
1461
+ this.registerMcpTool({
1462
+ pi,
1463
+ serverId: id,
1464
+ client: outcome.tools.client,
1465
+ tool
1466
+ });
1467
+ registered++;
1468
+ serverTokens += cost;
1469
+ summary.totalTools++;
1470
+ summary.tokensUsed += cost;
1471
+ }
1472
+ const dropped = advertised - registered;
1473
+ if (dropped > 0) summary.droppedTools += dropped;
1474
+ else droppedReason = null;
1475
+ summary.toolCounts[id] = {
1476
+ advertised,
1477
+ registered,
1478
+ tokens: serverTokens,
1479
+ droppedReason
1480
+ };
1037
1481
  }
1038
1482
  }
1039
1483
  return summary;
@@ -1190,6 +1634,64 @@ var McpExtension = class {
1190
1634
  try {
1191
1635
  mcpTools = (await connected.client.listTools()).tools;
1192
1636
  } catch (err) {
1637
+ if (isUnauthorizedError(err) && serverConfig.transport === "http") {
1638
+ await closeConnected(connected);
1639
+ const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
1640
+ if (retry.status === "pending_auth") return {
1641
+ id,
1642
+ store: {
1643
+ client: retry.client,
1644
+ configKey,
1645
+ status: "pending_auth",
1646
+ stderrBuffer: null,
1647
+ cliHint: retry.cliHint
1648
+ },
1649
+ serverStatus: {
1650
+ status: "pending_auth",
1651
+ stderr: "",
1652
+ cliHint: retry.cliHint
1653
+ },
1654
+ change: null,
1655
+ error: null,
1656
+ tools: null
1657
+ };
1658
+ if (retry.status === "failed") return {
1659
+ id,
1660
+ store: null,
1661
+ serverStatus: {
1662
+ status: "failed",
1663
+ error: retry.error,
1664
+ stderr: retry.stderr
1665
+ },
1666
+ change: null,
1667
+ error: {
1668
+ serverId: id,
1669
+ message: retry.error
1670
+ },
1671
+ tools: null
1672
+ };
1673
+ if (retry.status === "connected") {
1674
+ connected = {
1675
+ client: retry.client,
1676
+ configKey,
1677
+ status: "connected",
1678
+ stderrBuffer: retry.stderr,
1679
+ cliHint: null
1680
+ };
1681
+ mcpTools = (await connected.client.listTools()).tools;
1682
+ return {
1683
+ id,
1684
+ store: connected,
1685
+ serverStatus: { status: "connected" },
1686
+ change: action === "reused" ? "refreshed" : action,
1687
+ error: null,
1688
+ tools: {
1689
+ client: connected.client,
1690
+ list: mcpTools
1691
+ }
1692
+ };
1693
+ }
1694
+ }
1193
1695
  const message = err instanceof Error ? err.message : String(err);
1194
1696
  const stderr = connected.stderrBuffer?.read() ?? "";
1195
1697
  return {
@@ -1220,17 +1722,21 @@ var McpExtension = class {
1220
1722
  }
1221
1723
  };
1222
1724
  }
1223
- async reconcileAndRecordMtime({ pi, configPath, reason }) {
1725
+ async reconcileAndRecordMtime({ pi, configPath, reason, contextWindow }) {
1224
1726
  const summary = await this.reconcile({
1225
1727
  pi,
1226
- configPath
1728
+ configPath,
1729
+ contextWindow
1227
1730
  });
1228
1731
  this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1229
1732
  if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
1230
- log$11.info({
1733
+ log$12.info({
1231
1734
  event: "mcp_reconcile",
1232
1735
  reason,
1233
1736
  total_tools: summary.totalTools,
1737
+ tokens_used: summary.tokensUsed,
1738
+ token_budget: summary.tokenBudget,
1739
+ dropped_tools: summary.droppedTools,
1234
1740
  added: summary.added,
1235
1741
  removed: summary.removed,
1236
1742
  refreshed: summary.refreshed,
@@ -1247,10 +1753,11 @@ var McpExtension = class {
1247
1753
  await this.reconcileAndRecordMtime({
1248
1754
  pi,
1249
1755
  configPath,
1250
- reason: "session_start"
1756
+ reason: "session_start",
1757
+ contextWindow: ctx.model?.contextWindow
1251
1758
  });
1252
1759
  } catch (err) {
1253
- log$11.error({
1760
+ log$12.error({
1254
1761
  err,
1255
1762
  event: "mcp_reconcile_failed"
1256
1763
  }, "MCP reconcile failed");
@@ -1262,7 +1769,7 @@ var McpExtension = class {
1262
1769
  try {
1263
1770
  mtime = await readConfigMtimeMs(configPath);
1264
1771
  } catch (err) {
1265
- log$11.warn({
1772
+ log$12.warn({
1266
1773
  err,
1267
1774
  event: "mcp_mtime_check_failed"
1268
1775
  }, "mtime check on mcp.config.json failed");
@@ -1273,10 +1780,11 @@ var McpExtension = class {
1273
1780
  await this.reconcileAndRecordMtime({
1274
1781
  pi,
1275
1782
  configPath,
1276
- reason: "auto_reload"
1783
+ reason: "auto_reload",
1784
+ contextWindow: ctx.model?.contextWindow
1277
1785
  });
1278
1786
  } catch (err) {
1279
- log$11.error({
1787
+ log$12.error({
1280
1788
  err,
1281
1789
  event: "mcp_auto_reload_failed"
1282
1790
  }, "auto-reload after mcp.config.json change failed");
@@ -1293,7 +1801,8 @@ var McpExtension = class {
1293
1801
  const summary = await this.reconcileAndRecordMtime({
1294
1802
  pi,
1295
1803
  configPath,
1296
- reason: "tool"
1804
+ reason: "tool",
1805
+ contextWindow: ctx.model?.contextWindow
1297
1806
  });
1298
1807
  return {
1299
1808
  content: [{
@@ -1352,9 +1861,29 @@ function appendStderrBlock(lines, stderr) {
1352
1861
  for (const line of stderr.trimEnd().split("\n")) lines.push(` ${line}`);
1353
1862
  lines.push(" ---");
1354
1863
  }
1864
+ /**
1865
+ * Human-readable lines describing any budget-driven truncation, shared by
1866
+ * `summaryText` (the reload_mcp tool output) and `formatMcpUpdateMessage` (the
1867
+ * synthetic continuation). Empty when nothing was dropped.
1868
+ */
1869
+ function truncationLines(summary) {
1870
+ if (summary.droppedTools <= 0) return [];
1871
+ const lines = [];
1872
+ const { maxTotalTools, maxToolsPerServer } = summary.limits;
1873
+ const totalCapHint = Number.isFinite(maxTotalTools) ? `${maxTotalTools}` : "unlimited";
1874
+ lines.push(` WARNING: ${summary.droppedTools} MCP tool(s) were NOT registered because a tool budget was hit (token budget: ~${summary.tokenBudget} tokens, used ~${summary.tokensUsed}; total-tool cap: ${totalCapHint}, per-server cap: ${Number.isFinite(maxToolsPerServer) ? maxToolsPerServer : "unlimited"}).`);
1875
+ lines.push(" Tool definitions are sent to the model on every prompt; too many (or too-large) tool schemas push the request over the limit, so they are budgeted against the model context window. Prune servers from mcp.config.json to bring the tool surface down.");
1876
+ for (const [id, count] of Object.entries(summary.toolCounts)) {
1877
+ if (count.droppedReason === null) continue;
1878
+ const why = count.droppedReason === "tokens" ? "token budget exhausted" : count.droppedReason === "total" ? "total-tool cap reached" : "per-server cap";
1879
+ lines.push(` - ${id}: registered ${count.registered}/${count.advertised} tools (~${count.tokens} tokens, ${why}).`);
1880
+ }
1881
+ return lines;
1882
+ }
1355
1883
  function summaryText(summary) {
1356
1884
  const lines = [];
1357
1885
  lines.push(`MCP reconcile complete: ${summary.totalTools} tool(s) live.`);
1886
+ lines.push(...truncationLines(summary));
1358
1887
  if (summary.added.length > 0) lines.push(` Added: ${summary.added.join(", ")}`);
1359
1888
  if (summary.refreshed.length > 0) lines.push(` Refreshed: ${summary.refreshed.join(", ")}`);
1360
1889
  if (summary.removed.length > 0) lines.push(` Removed: ${summary.removed.join(", ")}`);
@@ -1380,7 +1909,7 @@ function summaryText(summary) {
1380
1909
  return lines.join("\n");
1381
1910
  }
1382
1911
  function summaryHasChanges(summary) {
1383
- return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0;
1912
+ return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0 || summary.droppedTools > 0;
1384
1913
  }
1385
1914
  /**
1386
1915
  * Format a queued tool-update as a synthetic system-style message for
@@ -1393,6 +1922,10 @@ function formatMcpUpdateMessage(summary) {
1393
1922
  if (summary.added.length > 0) lines.push(`Newly available servers: ${summary.added.join(", ")}`);
1394
1923
  if (summary.refreshed.length > 0) lines.push(`Refreshed servers: ${summary.refreshed.join(", ")}`);
1395
1924
  if (summary.removed.length > 0) lines.push(`Removed servers (and their tools): ${summary.removed.join(", ")}`);
1925
+ if (summary.droppedTools > 0) {
1926
+ lines.push("");
1927
+ lines.push(...truncationLines(summary));
1928
+ }
1396
1929
  const pending = pendingAuthEntries(summary);
1397
1930
  if (pending.length > 0) {
1398
1931
  lines.push("");
@@ -1639,16 +2172,380 @@ const bashDefaultTimeoutExtension = (pi) => {
1639
2172
  });
1640
2173
  };
1641
2174
  //#endregion
2175
+ //#region src/extensions/resource-pressure-warning.ts
2176
+ /**
2177
+ * Mid-run resource-pressure warning to the agent.
2178
+ *
2179
+ * The sandbox already detects pressure — the boot scripts cap the
2180
+ * user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
2181
+ * edges for memory and disk — but nothing told the *agent*, so a turn burned
2182
+ * straight to the OOM kill (or a full disk) and only learned about it from
2183
+ * the post-mortem notice. This extension closes that gap in-process: while a
2184
+ * turn is active it polls the agent cgroup and the root filesystem and, the
2185
+ * first time usage crosses a warn threshold, folds a system notification into
2186
+ * the open turn so the agent can checkpoint, shed work (constrain
2187
+ * parallelism, kill a background hog, clean scratch space), or request a
2188
+ * bigger tier BEFORE the kill.
2189
+ *
2190
+ * The notification is triggered by the two conditions that actually kill
2191
+ * work — memory near the cgroup hard cap, disk near full — and reports a
2192
+ * snapshot of all the relevant stats (memory, CPU utilization, disk) so the
2193
+ * agent can tell which resource is the problem and how much headroom the
2194
+ * others have.
2195
+ *
2196
+ * Edge-triggered, once per trigger per turn: the fired flags reset on
2197
+ * agent_start, so a turn that rides a threshold gets one warning per
2198
+ * resource, not a stream. Polling only runs while the agent is active — an
2199
+ * idle sandbox's resource usage is not the agent's problem and there is no
2200
+ * open turn to deliver into anyway.
2201
+ *
2202
+ * Best-effort throughout: any read failure (cgroup absent, controller not
2203
+ * delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
2204
+ * stat and the extension warns on what it can see — it must never break a
2205
+ * turn over an observability feature.
2206
+ */
2207
+ const execFileAsync = promisify(execFile);
2208
+ const log$11 = logger.child({ module: "resource-pressure-warning" });
2209
+ const POLL_INTERVAL_MS = 1e4;
2210
+ function envOverride(name) {
2211
+ for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
2212
+ const value = process.env[`${prefix}${name}`];
2213
+ if (value != null && value !== "") return value;
2214
+ }
2215
+ return null;
2216
+ }
2217
+ function cgroupDir() {
2218
+ return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
2219
+ }
2220
+ function diskRoot() {
2221
+ return envOverride("DISK_ROOT") ?? "/";
2222
+ }
2223
+ /**
2224
+ * Read a cgroup v2 scalar file. Returns a number, or null for "max"
2225
+ * (uncapped), an empty/absent file, or any read/parse error — an uncapped or
2226
+ * unreadable limit means there is nothing meaningful to warn against.
2227
+ */
2228
+ async function readScalar(file) {
2229
+ try {
2230
+ const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
2231
+ if (raw === "" || raw === "max") return null;
2232
+ const n = Number(raw);
2233
+ return Number.isFinite(n) ? n : null;
2234
+ } catch (_error) {
2235
+ return null;
2236
+ }
2237
+ }
2238
+ /**
2239
+ * Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
2240
+ * cpu.stat) and return the counter for `key`, or null when absent.
2241
+ */
2242
+ async function readKeyedCounter(file, key) {
2243
+ try {
2244
+ const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
2245
+ for (const line of raw.split("\n")) {
2246
+ const [k, v] = line.trim().split(/\s+/);
2247
+ if (k === key) {
2248
+ const n = Number(v);
2249
+ return Number.isFinite(n) ? n : null;
2250
+ }
2251
+ }
2252
+ return null;
2253
+ } catch (_error) {
2254
+ return null;
2255
+ }
2256
+ }
2257
+ /**
2258
+ * Live memory usage as an integer percent of the hard cap, or null when
2259
+ * either side is unreadable/uncapped. Exported for tests.
2260
+ */
2261
+ async function readMemUsePct() {
2262
+ const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
2263
+ if (current === null || max === null || max <= 0) return null;
2264
+ return {
2265
+ pct: Math.floor(current / max * 100),
2266
+ currentBytes: current,
2267
+ maxBytes: max
2268
+ };
2269
+ }
2270
+ /**
2271
+ * Root filesystem used% (df -P Capacity column), or null on any failure.
2272
+ * Exported for tests.
2273
+ */
2274
+ async function readDiskUsePct() {
2275
+ try {
2276
+ const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
2277
+ const dataRow = stdout.trim().split("\n")[1];
2278
+ if (dataRow == null) return null;
2279
+ const capacity = dataRow.trim().split(/\s+/)[4];
2280
+ if (capacity == null) return null;
2281
+ const pct = Number(capacity.replace("%", ""));
2282
+ return Number.isFinite(pct) ? pct : null;
2283
+ } catch (_error) {
2284
+ return null;
2285
+ }
2286
+ }
2287
+ /**
2288
+ * CPU utilization sampler. cgroup v2 exposes cumulative CPU time
2289
+ * (cpu.stat usage_usec); utilization is the delta between two samples over
2290
+ * the wall time between them, normalized by core count. The first call after
2291
+ * construction has no previous sample and returns null.
2292
+ */
2293
+ function createCpuSampler() {
2294
+ let prevUsageUsec = null;
2295
+ let prevAtMs = null;
2296
+ return async () => {
2297
+ const usage = await readKeyedCounter("cpu.stat", "usage_usec");
2298
+ const now = Date.now();
2299
+ const prev = prevUsageUsec;
2300
+ const prevAt = prevAtMs;
2301
+ prevUsageUsec = usage;
2302
+ prevAtMs = now;
2303
+ if (usage === null || prev === null || prevAt === null) return null;
2304
+ const wallUsec = (now - prevAt) * 1e3;
2305
+ if (wallUsec <= 0) return null;
2306
+ const cores = availableParallelism();
2307
+ const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
2308
+ return Math.max(0, Math.min(100, pct));
2309
+ };
2310
+ }
2311
+ function fmtMb(bytes) {
2312
+ return Math.round(bytes / 1024 / 1024);
2313
+ }
2314
+ /** The model-facing warning text. Exported for tests. */
2315
+ function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
2316
+ const stats = [];
2317
+ if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
2318
+ if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
2319
+ if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
2320
+ 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.`;
2321
+ 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.";
2322
+ 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>`;
2323
+ }
2324
+ const resourcePressureWarningExtension = (pi) => {
2325
+ let agentActive = false;
2326
+ let warnedMemThisTurn = false;
2327
+ let warnedDiskThisTurn = false;
2328
+ let timer = null;
2329
+ const sampleCpu = createCpuSampler();
2330
+ async function checkOnce() {
2331
+ if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
2332
+ const [mem, cpuPct, diskPct] = await Promise.all([
2333
+ readMemUsePct(),
2334
+ sampleCpu(),
2335
+ readDiskUsePct()
2336
+ ]);
2337
+ let trigger = null;
2338
+ if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
2339
+ trigger = "memory";
2340
+ warnedMemThisTurn = true;
2341
+ } else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
2342
+ trigger = "disk";
2343
+ warnedDiskThisTurn = true;
2344
+ }
2345
+ if (trigger === null) return;
2346
+ log$11.warn({
2347
+ trigger,
2348
+ mem,
2349
+ cpuPct,
2350
+ diskPct
2351
+ }, "resource pressure warning delivered to agent");
2352
+ await pi.sendMessage({
2353
+ customType: "anyone-resource-pressure-warning",
2354
+ content: resourcePressureWarningText(trigger, {
2355
+ mem,
2356
+ cpuPct,
2357
+ diskPct
2358
+ }),
2359
+ display: false
2360
+ }, {
2361
+ triggerTurn: true,
2362
+ deliverAs: "followUp"
2363
+ });
2364
+ }
2365
+ pi.on("agent_start", async () => {
2366
+ agentActive = true;
2367
+ warnedMemThisTurn = false;
2368
+ warnedDiskThisTurn = false;
2369
+ if (!timer) {
2370
+ timer = setInterval(() => {
2371
+ checkOnce().catch((err) => {
2372
+ log$11.error({ err }, "resource pressure check failed");
2373
+ });
2374
+ }, POLL_INTERVAL_MS);
2375
+ timer.unref?.();
2376
+ }
2377
+ });
2378
+ pi.on("agent_end", async () => {
2379
+ agentActive = false;
2380
+ if (timer) {
2381
+ clearInterval(timer);
2382
+ timer = null;
2383
+ }
2384
+ });
2385
+ };
2386
+ //#endregion
2387
+ //#region src/extensions/disk-guard.ts
2388
+ const log$10 = logger.child({ module: "disk-guard" });
2389
+ /**
2390
+ * In-band bypass. The guard is a safety net, not a jail: when the agent knows
2391
+ * a flagged command is genuinely safe (writing to a different mount, a tiny
2392
+ * bounded download, a delete-then-clone one-liner, an emergency it accepts the
2393
+ * risk on) it can force the command through by appending this marker as a
2394
+ * trailing shell comment. Kept as a comment so it never changes what the
2395
+ * command does, and matched case-insensitively with flexible spacing so the
2396
+ * agent doesn't have to reproduce it byte-for-byte.
2397
+ */
2398
+ const BYPASS_MARKER = /#\s*disk-guard:\s*allow\b/i;
2399
+ /** The exact marker text the block message tells the agent to append. */
2400
+ const BYPASS_HINT = "# disk-guard: allow";
2401
+ /**
2402
+ * Harness-level kill switch: set DISK_GUARD_DISABLE=1 to turn the guard off
2403
+ * entirely. This is the "I own my harness, let me opt out" knob — an agent
2404
+ * that boots its own harness can disable the guard for its whole process
2405
+ * without a code roll, and it's also the fleet-wide escape hatch if the
2406
+ * classifier ever misfires and blocks real work. The bare name is honored
2407
+ * first; the SKYDIVE_/ANYONE_ prefixes are accepted too for consistency with
2408
+ * the other env overrides. Empty/unset/"0"/"false" leave the guard on.
2409
+ */
2410
+ function guardDisabledByEnv() {
2411
+ for (const name of [
2412
+ "DISK_GUARD_DISABLE",
2413
+ "SKYDIVE_DISK_GUARD_DISABLE",
2414
+ "ANYONE_DISK_GUARD_DISABLE"
2415
+ ]) {
2416
+ const value = process.env[name];
2417
+ if (value != null && value !== "" && value !== "0" && value !== "false") return true;
2418
+ }
2419
+ return false;
2420
+ }
2421
+ /** True when the command carries the in-band bypass marker. */
2422
+ function hasBypassMarker(command) {
2423
+ return BYPASS_MARKER.test(command);
2424
+ }
2425
+ /**
2426
+ * Commands that reclaim space or merely inspect it. If any of these verbs
2427
+ * appears in the command line, we never block — otherwise the guard would trap
2428
+ * the agent by blocking the exact command it needs to dig out. Matched as
2429
+ * whole words so `remove-item` etc. don't accidentally match `rm`.
2430
+ */
2431
+ const RECLAIM_PATTERNS = [
2432
+ /\brm\b/,
2433
+ /\brmdir\b/,
2434
+ /\bdf\b/,
2435
+ /\bdu\b/,
2436
+ /\bncdu\b/,
2437
+ /\bfind\b[^|]*\s-delete\b/,
2438
+ /\btruncate\b/,
2439
+ /\bgit\s+(gc|prune|clean|worktree\s+remove|worktree\s+prune)\b/,
2440
+ /\b(yarn|npm|pnpm|bun)\s+.*\b(cache\s+clean|cache\s+clear|store\s+prune)\b/,
2441
+ /\bcache\s+(clean|clear|prune)\b/,
2442
+ /\b(docker|podman)\s+.*\bprune\b/,
2443
+ /\bapt(-get)?\s+clean\b/,
2444
+ /\bjournalctl\b[^|]*--vacuum/
2445
+ ];
2446
+ /**
2447
+ * File extensions that mean a download is actually LARGE — archives, disk
2448
+ * images, compiled/binary artifacts, model weights, media. A curl/wget is only
2449
+ * gated when it writes one of these; an API/page fetch to a `.json`/`.html`/
2450
+ * `.txt` file is tiny and must not be blocked. Derived from 4,144 real
2451
+ * commands: ~64% of `curl -o` uses were tiny fetches, only ~4% large.
2452
+ */
2453
+ 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)";
2454
+ /**
2455
+ * Commands that consume a meaningful amount of disk. Kept deliberately tight
2456
+ * and high-precision: validated against 4,144 real commands from the last 7
2457
+ * days, the earlier "writes a file" heuristic flagged 82% of everything (a
2458
+ * `curl -o /tmp/x.json` API call is not a disk event). This set flags ~33%,
2459
+ * almost all genuinely large — real installs, clones, big-archive downloads,
2460
+ * extractions. What was DROPPED and why:
2461
+ * - `git fetch` / `git pull` — incremental on an existing clone, usually tiny.
2462
+ * - `git checkout` — overwhelmingly `git checkout <ref> -- <file>` or a
2463
+ * branch switch, ~zero net growth; the rare full materialization isn't
2464
+ * worth the false-positive rate.
2465
+ * - bare `curl -o` / `wget -o` — see BIG_DOWNLOAD_EXT above.
2466
+ * - loose `… build` — matched `--mode=skip-build`, `oxfmt … build`, prose.
2467
+ * The remaining big-disk op in escher is `git clone` and `git worktree add`
2468
+ * (which is really a checkout), both kept.
2469
+ */
2470
+ const SPACE_HUNGRY_PATTERNS = [
2471
+ /\bgit\s+clone\b/,
2472
+ /\bgit\s+worktree\s+add\b/,
2473
+ /\b(yarn|npm|pnpm|bun)\s+(install|add|ci)\b/,
2474
+ /\byarn\s*$/,
2475
+ /\byarn\s+--(?!version|help)\S/,
2476
+ /\bpip3?\s+install\b/,
2477
+ /\bapt(-get)?\s+install\b/,
2478
+ /\bnpm\s+pack\b/,
2479
+ /\bdocker\s+(build|pull)\b/,
2480
+ new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b`, "i"),
2481
+ new RegExp(`\\b(?:curl|wget)\\b[^\\n]*\\.${BIG_DOWNLOAD_EXT}\\b[^\\n]*\\s-[a-zA-Z]*[oO]\\b`, "i"),
2482
+ /\btar\s+[^\n|]*x[^\n|]*f/,
2483
+ /\bunzip\b/,
2484
+ /\bdd\b[^\n|]*\bof=/
2485
+ ];
2486
+ /**
2487
+ * True when the command reclaims or inspects space — these are always allowed,
2488
+ * even on a 100%-full box, so the agent can dig itself out.
2489
+ */
2490
+ function isReclaimCommand(command) {
2491
+ return RECLAIM_PATTERNS.some((re) => re.test(command));
2492
+ }
2493
+ /**
2494
+ * True when the command is likely to consume a meaningful amount of disk.
2495
+ * A reclaim/inspect command is never space-hungry — the reclaim check wins so a
2496
+ * `git worktree remove` or a `yarn cache clean` is never mistaken for growth.
2497
+ */
2498
+ function isSpaceHungryCommand(command) {
2499
+ if (isReclaimCommand(command)) return false;
2500
+ return SPACE_HUNGRY_PATTERNS.some((re) => re.test(command));
2501
+ }
2502
+ /**
2503
+ * The decision, factored out and pure so it's exhaustively testable without a
2504
+ * real filesystem. Block only when we have a disk reading, it's at/above the
2505
+ * critical threshold, the command is space-hungry (and not a reclaim), and the
2506
+ * agent hasn't explicitly opted out with the bypass marker.
2507
+ */
2508
+ function shouldBlockForDisk(command, diskPct) {
2509
+ if (diskPct === null) return false;
2510
+ if (diskPct < 95) return false;
2511
+ if (hasBypassMarker(command)) return false;
2512
+ return isSpaceHungryCommand(command);
2513
+ }
2514
+ /** The agent-facing explanation returned as the blocked tool result. */
2515
+ function diskBlockReason(command, diskPct) {
2516
+ 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.`;
2517
+ }
2518
+ const diskGuardExtension = (pi) => {
2519
+ pi.on("tool_call", async (event) => {
2520
+ if (event.toolName !== "bash") return;
2521
+ if (guardDisabledByEnv()) return;
2522
+ const command = event.input.command;
2523
+ if (typeof command !== "string" || command.length === 0) return;
2524
+ if (hasBypassMarker(command)) return;
2525
+ if (!isSpaceHungryCommand(command)) return;
2526
+ const diskPct = await readDiskUsePct();
2527
+ if (!shouldBlockForDisk(command, diskPct)) return;
2528
+ log$10.warn({
2529
+ diskPct,
2530
+ command: command.slice(0, 200)
2531
+ }, "blocked space-hungry bash command on near-full disk");
2532
+ return {
2533
+ block: true,
2534
+ reason: diskBlockReason(command, diskPct)
2535
+ };
2536
+ });
2537
+ };
2538
+ //#endregion
1642
2539
  //#region src/channel-context-ref.ts
1643
2540
  /**
1644
- * The worker injects only a reference — `{ channel, messageId }` — into the
1645
- * sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
2541
+ * The worker injects a small reference — `{ channel, messageId, runId }` —
2542
+ * into the sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
1646
2543
  *
1647
2544
  * The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
1648
2545
  * `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
1649
2546
  * `@createinc/*` dependencies — importing that package would pull the whole
1650
- * platform channel stack (Slack/email/Linq SDKs, messaging) in just to read
1651
- * two fields. So we validate the (stable) shape locally instead.
2547
+ * platform channel stack (Slack/email/Linq SDKs, messaging) just to read one
2548
+ * field. So we validate the field this consumer needs locally instead.
1652
2549
  */
1653
2550
  const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
1654
2551
  /**
@@ -1680,20 +2577,84 @@ function apiBaseUrl() {
1680
2577
  }
1681
2578
  //#endregion
1682
2579
  //#region src/extensions/platform.ts
2580
+ /**
2581
+ * Platform extension — bridges the agent harness to the Skydive platform daemon.
2582
+ *
2583
+ * Responsibilities:
2584
+ * - Heartbeat: periodic POST to the API so the sandbox manager knows the
2585
+ * agent is alive. Throttled to once per minute, triggered by tool events.
2586
+ * - Session tracking: registers the session with the daemon on start,
2587
+ * streams tool_call / tool_result events so the daemon can track which
2588
+ * session is actively executing, and signals session end on agent_end.
2589
+ * - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
2590
+ * messageId) to the daemon so file writes can be attributed to the
2591
+ * correct conversation.
2592
+ *
2593
+ * All daemon POSTs are fire-and-forget — failures are logged but never
2594
+ * block the agent. The daemon may not be running (e.g. local dev without
2595
+ * a sandbox), and that's fine.
2596
+ */
1683
2597
  const HEARTBEAT_THROTTLE_MS = 6e4;
1684
2598
  const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
1685
2599
  const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
1686
2600
  const DAEMON_URL = "http://localhost:38994";
1687
- const log$10 = logger.child({ module: "platform-ext" });
2601
+ const log$9 = logger.child({ module: "platform-ext" });
1688
2602
  function sandboxClient() {
1689
2603
  const apiUrl = apiBaseUrl();
1690
2604
  if (!apiUrl) return null;
1691
2605
  return hc(`${apiUrl}/api/v1/sandbox`);
1692
2606
  }
1693
2607
  /**
1694
- * Fetch every harness feature flag in one GET (`{ contextManagement, subagent,
1695
- * ... }` see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1696
- * null when indeterminate (no api url, or the request failed) so the shared
2608
+ * Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
2609
+ * feature-flags half of the ANY-5184 pool 403 wave.)
2610
+ *
2611
+ * `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
2612
+ * from a pool box can only 403 — a guaranteed-failing GET every 60s for the
2613
+ * life of the pool phase. The discriminator is the sandbox token's `type`
2614
+ * claim, read UNVERIFIED (this box never holds the signing secret): not an
2615
+ * authorization decision, only "should I bother calling?", and the api still
2616
+ * authorizes every request.
2617
+ *
2618
+ * Read per call from the daemon's persisted env file, NOT process.env:
2619
+ * claiming a pool box rebinds the token in place (the daemon rewrites this
2620
+ * file) while the harness's process.env keeps the boot snapshot, so a
2621
+ * process-env gate would leave a claimed box permanently skipping — trading a
2622
+ * wasted request for silently frozen flags, which is strictly worse. "Cannot
2623
+ * tell" (no file, no token, unparseable payload) reports false so the poll
2624
+ * proceeds.
2625
+ */
2626
+ const daemonEnvIdentitySchema = z.object({
2627
+ ANYONE_SANDBOX_TOKEN: z.string().optional(),
2628
+ SKYDIVE_SANDBOX_TOKEN: z.string().optional()
2629
+ }).passthrough();
2630
+ const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
2631
+ async function isPoolIdentity() {
2632
+ try {
2633
+ const override = process.env.ANYONE_DAEMON_ENV_CACHE;
2634
+ const candidates = override ? [override] : ["/run/anyone-system/daemon-env.json", "/tmp/.anyone/daemon-env.json"];
2635
+ let raw = null;
2636
+ for (const file of candidates) {
2637
+ raw = await readFile(file, "utf8").catch(() => null);
2638
+ if (raw !== null) break;
2639
+ }
2640
+ if (raw === null) return false;
2641
+ const env = daemonEnvIdentitySchema.safeParse(JSON.parse(raw));
2642
+ if (!env.success) return false;
2643
+ const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
2644
+ if (typeof token !== "string" || token === "") return false;
2645
+ const payload = token.split(".")[1];
2646
+ if (!payload) return false;
2647
+ const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
2648
+ return claims.success && claims.data.type === "onboarding-pool";
2649
+ } catch (_err) {
2650
+ return false;
2651
+ }
2652
+ }
2653
+ /**
2654
+ * Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
2655
+ * — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
2656
+ * null when indeterminate (no api url, the request failed, or the box is an
2657
+ * unclaimed pool sandbox whose token the route would 403) so the shared
1697
2658
  * poller keeps the last-known values rather than flipping on a transient error.
1698
2659
  * This is the single fetch behind `feature-flags-poll.ts`; extensions read the
1699
2660
  * polled values there instead of issuing their own GET.
@@ -1701,22 +2662,19 @@ function sandboxClient() {
1701
2662
  async function fetchHarnessFlags() {
1702
2663
  const client = sandboxClient();
1703
2664
  if (!client) return null;
2665
+ if (await isPoolIdentity()) return null;
1704
2666
  try {
1705
2667
  const res = await client["feature-flags"].$get();
1706
2668
  if (!res.ok) {
1707
- log$10.debug({
2669
+ log$9.debug({
1708
2670
  status: res.status,
1709
2671
  event: "feature_flags_fetch_failed"
1710
2672
  }, "feature-flags fetch failed");
1711
2673
  return null;
1712
2674
  }
1713
- const body = await res.json();
1714
- return {
1715
- contextManagement: body.contextManagement ?? null,
1716
- subagent: body.subagent ?? null
1717
- };
2675
+ return { contextManagement: (await res.json()).contextManagement ?? null };
1718
2676
  } catch (err) {
1719
- log$10.debug({
2677
+ log$9.debug({
1720
2678
  err,
1721
2679
  event: "feature_flags_fetch_error"
1722
2680
  }, "feature-flags request errored");
@@ -1727,7 +2685,7 @@ function postHeartbeat({ messageId }) {
1727
2685
  const client = sandboxClient();
1728
2686
  if (!client) return;
1729
2687
  client.heartbeat.$post({ json: { messageId } }).catch((err) => {
1730
- log$10.debug({
2688
+ log$9.debug({
1731
2689
  err,
1732
2690
  event: "heartbeat_failed"
1733
2691
  }, "heartbeat failed");
@@ -1739,7 +2697,7 @@ async function resolveConversationFromApi(messageId) {
1739
2697
  try {
1740
2698
  const res = await client["message-conversation"].$get({ query: { messageId } });
1741
2699
  if (!res.ok) {
1742
- log$10.warn({
2700
+ log$9.warn({
1743
2701
  status: res.status,
1744
2702
  messageId,
1745
2703
  event: "resolve_conversation_failed"
@@ -1748,7 +2706,7 @@ async function resolveConversationFromApi(messageId) {
1748
2706
  }
1749
2707
  return (await res.json()).conversationId ?? null;
1750
2708
  } catch (err) {
1751
- log$10.warn({
2709
+ log$9.warn({
1752
2710
  err,
1753
2711
  messageId,
1754
2712
  event: "resolve_conversation_error"
@@ -1765,6 +2723,71 @@ async function postBackgroundTaskDone({ messageId, content }) {
1765
2723
  } });
1766
2724
  if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
1767
2725
  }
2726
+ async function putBackgroundTaskJournalSpec({ messageId, spec }) {
2727
+ const client = sandboxClient();
2728
+ if (!client) return;
2729
+ try {
2730
+ const res = await client["bg-task-journal"].$put({ json: {
2731
+ messageId,
2732
+ spec
2733
+ } });
2734
+ if (!res.ok) log$9.warn({
2735
+ status: res.status,
2736
+ taskId: spec.id,
2737
+ event: "bg_journal_put_failed"
2738
+ }, "bg-task journal PUT failed");
2739
+ } catch (err) {
2740
+ log$9.warn({
2741
+ err,
2742
+ taskId: spec.id,
2743
+ event: "bg_journal_put_failed"
2744
+ }, "bg-task journal PUT threw");
2745
+ }
2746
+ }
2747
+ async function deleteBackgroundTaskJournalSpec({ messageId, taskId }) {
2748
+ const client = sandboxClient();
2749
+ if (!client) return;
2750
+ try {
2751
+ await client["bg-task-journal"].$delete({ json: {
2752
+ messageId,
2753
+ taskId
2754
+ } });
2755
+ } catch (err) {
2756
+ log$9.debug({
2757
+ err,
2758
+ taskId,
2759
+ event: "bg_journal_delete_failed"
2760
+ }, "bg-task journal DELETE failed");
2761
+ }
2762
+ }
2763
+ async function listBackgroundTaskJournalSpecs({ messageId }) {
2764
+ const client = sandboxClient();
2765
+ if (!client) return [];
2766
+ try {
2767
+ const res = await client["bg-task-journal"].$get({ query: { messageId } });
2768
+ if (!res.ok) return [];
2769
+ return (await res.json()).specs ?? [];
2770
+ } catch (err) {
2771
+ log$9.debug({
2772
+ err,
2773
+ event: "bg_journal_list_failed"
2774
+ }, "bg-task journal GET failed");
2775
+ return [];
2776
+ }
2777
+ }
2778
+ function postBackgroundTasksSnapshot({ messageId, tasks }) {
2779
+ const client = sandboxClient();
2780
+ if (!client || !messageId) return;
2781
+ client["bg-tasks"].$post({ json: {
2782
+ messageId,
2783
+ tasks
2784
+ } }).catch((err) => {
2785
+ log$9.debug({
2786
+ err,
2787
+ event: "bg_tasks_snapshot_failed"
2788
+ }, "bg-tasks snapshot publish failed");
2789
+ });
2790
+ }
1768
2791
  async function postSubagentSpawn({ messageId, tasks }) {
1769
2792
  const client = sandboxClient();
1770
2793
  if (!client) throw new Error("no api url for subagent-spawn");
@@ -1772,8 +2795,19 @@ async function postSubagentSpawn({ messageId, tasks }) {
1772
2795
  messageId,
1773
2796
  tasks
1774
2797
  } });
1775
- if (!res.ok) throw new Error(`subagent-spawn POST failed: ${res.status}`);
1776
- return { taskIds: (await res.json()).taskIds };
2798
+ if (!res.ok) {
2799
+ let detail = "";
2800
+ try {
2801
+ const errBody = await res.json();
2802
+ if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
2803
+ } catch {}
2804
+ throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
2805
+ }
2806
+ const body = await res.json();
2807
+ return {
2808
+ taskIds: body.taskIds,
2809
+ tasks: body.tasks ?? []
2810
+ };
1777
2811
  }
1778
2812
  function createHeartbeatThrottle({ messageId }) {
1779
2813
  let lastAt = 0;
@@ -1822,7 +2856,7 @@ function createToolHeartbeat({ messageId }) {
1822
2856
  }
1823
2857
  heartbeatCount++;
1824
2858
  if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
1825
- log$10.warn({
2859
+ log$9.warn({
1826
2860
  heartbeatCount,
1827
2861
  activeToolCalls: [...activeToolCalls]
1828
2862
  }, "tool heartbeat max reached, stopping");
@@ -1853,7 +2887,7 @@ function postToDaemon(path, body) {
1853
2887
  headers: { "content-type": "application/json" },
1854
2888
  body: JSON.stringify(body)
1855
2889
  }).catch((err) => {
1856
- log$10.debug({
2890
+ log$9.debug({
1857
2891
  err,
1858
2892
  path,
1859
2893
  event: "daemon_post_failed"
@@ -1862,7 +2896,7 @@ function postToDaemon(path, body) {
1862
2896
  }
1863
2897
  function createPlatformExtensions({ sessionId, channelContext }) {
1864
2898
  return (pi) => {
1865
- log$10.info({
2899
+ log$9.info({
1866
2900
  sessionId,
1867
2901
  hasChannelContext: Boolean(channelContext)
1868
2902
  }, "platform extension initialized");
@@ -1911,7 +2945,7 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1911
2945
  });
1912
2946
  });
1913
2947
  pi.on("agent_end", () => {
1914
- log$10.info({ sessionId }, "session ending");
2948
+ log$9.info({ sessionId }, "session ending");
1915
2949
  postToDaemon("/session/end", { sessionId });
1916
2950
  });
1917
2951
  };
@@ -1922,37 +2956,29 @@ function createPlatformExtensions({ sessionId, channelContext }) {
1922
2956
  * Shared harness feature-flag poll.
1923
2957
  *
1924
2958
  * The api exposes one `/feature-flags` GET that returns every harness flag in a
1925
- * single response (`{ contextManagement, subagent, commandFlags }` — see
2959
+ * single response (`{ contextManagement, commandFlags }` — see
1926
2960
  * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1927
2961
  * extension issuing its own GET — and, worse, a *blocking* GET on the
1928
2962
  * pre-first-token `session_start` path — a single background poller fetches
1929
2963
  * that response once per interval and fans the values out to every subscriber.
1930
2964
  *
1931
- * Why one poller: the subagent extension gates its tool registration on the
1932
- * `subagent` flag. If it awaited a fresh GET inside `session_start` the tool
1933
- * schema (part of the prefill) couldn't be finalized until a serial
1934
- * sandbox→api round-trip settled, adding a net-new pre-token network hop on
1935
- * every session, flag on or off. Reading the last-polled value instead keeps
1936
- * the hot path allocation-only. A cold cache reads as `null` (fail-open to
1937
- * unregistered); a newly-flipped flag takes effect on the next poll, matching
1938
- * how context-management already treats its flag.
2965
+ * Why one poller: context-management consumes the `contextManagement` flag
2966
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
2967
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
2968
+ * as `null` and a newly-flipped flag takes effect on the next poll.
1939
2969
  *
1940
2970
  * The poll is fire-and-forget and self-unref'd — it never keeps the process
1941
2971
  * alive and an indeterminate result (no api url / transient failure) leaves the
1942
2972
  * last-known values untouched so a blip can't silently flip behavior.
1943
2973
  */
1944
- const log$9 = logger.child({ module: "feature-flags-poll" });
2974
+ const log$8 = logger.child({ module: "feature-flags-poll" });
1945
2975
  const FLAG_POLL_INTERVAL_MS = 6e4;
1946
2976
  let contextManagement = null;
1947
- let subagent = null;
1948
- const subscribers = {
1949
- contextManagement: /* @__PURE__ */ new Set(),
1950
- subagent: /* @__PURE__ */ new Set()
1951
- };
2977
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
1952
2978
  let pollerStarted = false;
1953
2979
  let firstPollSettled = false;
1954
2980
  let resolveFirstPoll = null;
1955
- const firstPollPromise = new Promise((resolve) => {
2981
+ new Promise((resolve) => {
1956
2982
  resolveFirstPoll = resolve;
1957
2983
  });
1958
2984
  function markFirstPollSettled() {
@@ -1961,20 +2987,8 @@ function markFirstPollSettled() {
1961
2987
  resolveFirstPoll?.();
1962
2988
  }
1963
2989
  /** Last-polled value of a flag, or `null` if not yet resolved. */
1964
- function getPolledFlag(name) {
1965
- return name === "contextManagement" ? contextManagement : subagent;
1966
- }
1967
- /**
1968
- * Await the first poll already kicked by `startFeatureFlagPoller` (never a new
1969
- * GET). Resolves when that poll settles, immediately if it already has, or
1970
- * immediately when there's no flag source to poll. Callers on the hot path
1971
- * should race this against their own short timeout so a slow/failed flag
1972
- * service cannot delay first-token; a timeout just means the caller reads the
1973
- * still-cold cache and falls back to its default, exactly as before.
1974
- */
1975
- function awaitFirstFlagPoll() {
1976
- if (firstPollSettled || !hasFlagSource()) return Promise.resolve();
1977
- return firstPollPromise;
2990
+ function getPolledFlag(_name) {
2991
+ return contextManagement;
1978
2992
  }
1979
2993
  /**
1980
2994
  * Subscribe to changes of a flag. The callback fires only on a *transition*
@@ -1987,13 +3001,12 @@ function onFlagChange(name, cb) {
1987
3001
  }
1988
3002
  function apply(name, next) {
1989
3003
  if (next === null) return;
1990
- const prev = name === "contextManagement" ? contextManagement : subagent;
1991
- if (name === "contextManagement") contextManagement = next;
1992
- else subagent = next;
3004
+ const prev = contextManagement;
3005
+ contextManagement = next;
1993
3006
  if (next !== prev) for (const cb of subscribers[name]) try {
1994
3007
  cb(next);
1995
3008
  } catch (err) {
1996
- log$9.warn({
3009
+ log$8.warn({
1997
3010
  err,
1998
3011
  flag: name
1999
3012
  }, "flag subscriber threw");
@@ -2004,9 +3017,8 @@ async function pollOnce() {
2004
3017
  const flags = await fetchHarnessFlags();
2005
3018
  if (!flags) return;
2006
3019
  apply("contextManagement", flags.contextManagement ?? null);
2007
- apply("subagent", flags.subagent ?? null);
2008
3020
  } catch (err) {
2009
- log$9.debug({ err }, "feature-flag poll threw");
3021
+ log$8.debug({ err }, "feature-flag poll threw");
2010
3022
  }
2011
3023
  }
2012
3024
  /**
@@ -2113,7 +3125,7 @@ function transformContextMessages(messages, config, now) {
2113
3125
  }
2114
3126
  //#endregion
2115
3127
  //#region src/extensions/context-management.ts
2116
- const log$8 = logger.child({ module: "context-management-extension" });
3128
+ const log$7 = logger.child({ module: "context-management-extension" });
2117
3129
  function isAnthropicMessagesPayload(payload) {
2118
3130
  if (typeof payload !== "object" || payload === null) return false;
2119
3131
  const candidate = payload;
@@ -2178,13 +3190,13 @@ function createContextManagementExtension() {
2178
3190
  setContextManagementFlagOverride(getPolledFlag("contextManagement"));
2179
3191
  onFlagChange("contextManagement", (enabled) => {
2180
3192
  setContextManagementFlagOverride(enabled);
2181
- log$8.info({
3193
+ log$7.info({
2182
3194
  event: "context_management_flag_update",
2183
3195
  enabled
2184
3196
  }, "context-management flag updated from platform");
2185
3197
  });
2186
3198
  startFeatureFlagPoller();
2187
- log$8.info({
3199
+ log$7.info({
2188
3200
  event: "context_management_registered",
2189
3201
  enabled: initial.enabled,
2190
3202
  flagSource: hasFlagSource(),
@@ -2196,13 +3208,13 @@ function createContextManagementExtension() {
2196
3208
  const { messages } = event;
2197
3209
  try {
2198
3210
  const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
2199
- if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$8.info({
3211
+ if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$7.info({
2200
3212
  event: "context_management_applied",
2201
3213
  ...result.stats
2202
3214
  }, "trimmed/cleared tool output before LLM call");
2203
3215
  return { messages: result.messages };
2204
3216
  } catch (err) {
2205
- log$8.error({
3217
+ log$7.error({
2206
3218
  err,
2207
3219
  event: "context_management_transform_failed"
2208
3220
  }, "context transform failed; passing messages through unchanged");
@@ -2214,7 +3226,7 @@ function createContextManagementExtension() {
2214
3226
  }
2215
3227
  //#endregion
2216
3228
  //#region src/extensions/current-time.ts
2217
- const log$7 = logger.child({ module: "current-time-extension" });
3229
+ const log$6 = logger.child({ module: "current-time-extension" });
2218
3230
  const PI_DATE_LINE = /^Current date:.*$/m;
2219
3231
  function formatCurrentTimeLine(now) {
2220
3232
  return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
@@ -2227,7 +3239,7 @@ const currentTimeExtension = (pi) => {
2227
3239
  const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
2228
3240
  const base = event.systemPrompt;
2229
3241
  if (PI_DATE_LINE.test(base)) {
2230
- log$7.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
3242
+ log$6.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
2231
3243
  return { systemPrompt: base.replace(PI_DATE_LINE, line) };
2232
3244
  }
2233
3245
  return { systemPrompt: `${base}\n${line}` };
@@ -2236,37 +3248,35 @@ const currentTimeExtension = (pi) => {
2236
3248
  //#endregion
2237
3249
  //#region src/memory.ts
2238
3250
  /**
2239
- * In-harness memory index builder.
3251
+ * In-harness memory readers.
2240
3252
  *
2241
- * The agent has an agent-level file-based memory at `<cwd>/.memory/`,
2242
- * organized by directory:
3253
+ * The agent has an agent-level file-based memory at `<cwd>/.memory/`, split
3254
+ * into two halves that are surfaced differently:
2243
3255
  *
2244
- * .memory/users/<id>-<name>/<topic>.md
2245
- * .memory/projects/<project_slug>/<topic>.md
2246
- * .memory/feedback/<topic>.md
2247
- * .memory/reference/<topic>.md
3256
+ * 1. Shared knowledge (projects, lessons, external systems) — indexed by a
3257
+ * single hand-maintained `<cwd>/.memory/MEMORY.md` that the *agent* writes
3258
+ * and curates, Claude-Code style: one line per fact pointing at the file
3259
+ * that holds it. `readMemoryIndexFile` just reads that file; the agent owns
3260
+ * its contents. This is the whole index for the shared half — there is no
3261
+ * derived walk and no per-directory `MEMORY.md`.
2248
3262
  *
2249
- * The path encodes type and subject (for `users/`, the subject is the
2250
- * person's stable id with a readable name suffix); each `.md` file's
2251
- * frontmatter only carries `name` and `description`.
3263
+ * 2. Per-person memory `.memory/users/<id>-<name>/<topic>.md`. This half is
3264
+ * *derived*, not hand-maintained, because it has to be filtered to the one
3265
+ * person on the current turn (a single hand-written index couldn't be
3266
+ * scoped per-user without leaking one person's notes into another's
3267
+ * conversation). `buildMemoryIndex` walks a single user's directory, reads
3268
+ * only the frontmatter of each `.md` (open fd → read first ~4KB → close, in
3269
+ * parallel), and renders an index. Each `.md`'s frontmatter carries `name`
3270
+ * and `description`; bodies are never read — the agent loads a specific
3271
+ * memory's body on demand via the `read` tool.
2252
3272
  *
2253
- * `buildMemoryIndex` walks `.memory/` by type directory, reads only the
2254
- * frontmatter of each `.md` (open fd read first ~4KB close, in
2255
- * parallel), and renders a markdown index grouped by type and (where
2256
- * applicable) by subject. Files outside the four type directories are
2257
- * ignored. Bodies are never read — the agent loads a specific memory's
2258
- * body on demand via the `read` tool when the index entry says it's
2259
- * relevant.
3273
+ * Mtime cache (for the derived per-user half) keyed by cwd within the
3274
+ * lifetime of a sandbox the cwd is fixed, so this is effectively a single-entry
3275
+ * cache. It invalidates when any `.md` under `users/` is added/modified/
3276
+ * deleted; turns where memory didn't change reuse the cached entries.
2260
3277
  *
2261
- * Mtime cache keyed by cwd within the lifetime of a sandbox the cwd
2262
- * is fixed, so this is effectively a single-entry cache. Cache invalidates
2263
- * when any `.md` in the tree is added/modified/deleted; turns where
2264
- * memory didn't change reuse the cached string.
2265
- *
2266
- * Frontmatter is parsed as YAML (`yaml` package) and validated with a
2267
- * zod schema — files that don't match the shape are dropped from the
2268
- * index. The same schema can be reused at write time if we want to
2269
- * validate before commit.
3278
+ * Frontmatter is parsed as YAML (`yaml` package) and validated with a zod
3279
+ * schema files that don't match the shape are dropped from the index.
2270
3280
  */
2271
3281
  const FRONTMATTER_READ_BYTES = 4096;
2272
3282
  const FrontmatterSchema = z.object({
@@ -2274,33 +3284,118 @@ const FrontmatterSchema = z.object({
2274
3284
  description: z.string().min(1)
2275
3285
  }).passthrough();
2276
3286
  const MEMORY_DIRNAME = ".memory";
2277
- const TYPE_DIRS = [
2278
- "users",
2279
- "projects",
2280
- "feedback",
2281
- "reference"
3287
+ const MEMORY_INDEX_FILENAME = "MEMORY.md";
3288
+ const USERS_DIRNAME = "users";
3289
+ /**
3290
+ * The shared-knowledge type dirs from the old frontmatter-indexed layout, used
3291
+ * only to seed a `MEMORY.md` for agents created before it existed (see
3292
+ * `seedMemoryIndexFile`). `users/` is deliberately excluded — per-person memory
3293
+ * stays derived and never lands in the shared, un-scoped `MEMORY.md`.
3294
+ */
3295
+ const LEGACY_SHARED_TYPES = [
3296
+ {
3297
+ dir: "projects",
3298
+ label: "Projects"
3299
+ },
3300
+ {
3301
+ dir: "feedback",
3302
+ label: "Feedback"
3303
+ },
3304
+ {
3305
+ dir: "reference",
3306
+ label: "Reference"
3307
+ }
2282
3308
  ];
2283
- const TYPES_WITH_SUBJECT = new Set(["users", "projects"]);
2284
- const TYPE_LABELS = {
2285
- users: "Users",
2286
- projects: "Projects",
2287
- feedback: "Feedback",
2288
- reference: "Reference"
2289
- };
3309
+ /**
3310
+ * Soft budget for an injected index block. The index is read and injected into
3311
+ * the system prompt on every turn, so every entry costs context for the rest of
3312
+ * the conversation. Past this size we nudge the agent to consolidate and prune
3313
+ * rather than keep appending. Not a hard cap — nothing is truncated.
3314
+ */
3315
+ const MEMORY_INDEX_SOFT_BUDGET_CHARS = 2e4;
3316
+ /**
3317
+ * Hard cap on the injected index — double the soft budget. The soft budget only
3318
+ * warns; this actually bounds what we inject so a runaway index can't consume
3319
+ * unbounded context on every turn. Past this, the index is truncated (on a line
3320
+ * boundary) before injection. It's a backstop, not a normal operating point.
3321
+ */
3322
+ const MEMORY_INDEX_HARD_BUDGET_CHARS = MEMORY_INDEX_SOFT_BUDGET_CHARS * 2;
3323
+ /**
3324
+ * Cheap size summary of a rendered index block, used to surface how much of the
3325
+ * every-turn context budget the index is spending so the agent keeps it lean.
3326
+ * Counts pointer/file lines — both the derived `` - `path` — desc`` form and
3327
+ * the hand-maintained `- [Title](path) — hook` form — not the group headers.
3328
+ */
3329
+ function summarizeIndex(index) {
3330
+ const entryCount = index.split("\n").filter((line) => /^\s*- (?:`|\[)/.test(line)).length;
3331
+ const charCount = index.length;
3332
+ return {
3333
+ entryCount,
3334
+ charCount,
3335
+ overBudget: charCount > MEMORY_INDEX_SOFT_BUDGET_CHARS
3336
+ };
3337
+ }
3338
+ /**
3339
+ * One-line size note for an index block header, e.g. `12 entries, 3187 chars`.
3340
+ */
3341
+ function indexSizeNote(index) {
3342
+ const { entryCount, charCount } = summarizeIndex(index);
3343
+ return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}, ${charCount} chars`;
3344
+ }
3345
+ /**
3346
+ * An explicit warning to surface to the agent when an index has grown past its
3347
+ * budget, or `null` when it's within budget. Extensions render this prominently
3348
+ * above the index so the agent prunes before it keeps appending.
3349
+ */
3350
+ function indexBudgetWarning(index) {
3351
+ const { charCount, overBudget } = summarizeIndex(index);
3352
+ if (!overBudget) return null;
3353
+ 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.`;
3354
+ }
3355
+ /**
3356
+ * Enforce the hard cap on an index before injection. Under the cap the index is
3357
+ * returned unchanged; over it, the index is truncated on a line boundary and a
3358
+ * notice is appended naming the true size so the agent knows entries are hidden
3359
+ * and must be pruned. This is the actual bound on injected context — callers
3360
+ * still report the true size via {@link indexSizeNote} so nothing is masked.
3361
+ */
3362
+ function enforceMemoryIndexHardBudget(index) {
3363
+ if (index.length <= 4e4) return index;
3364
+ const clipped = index.slice(0, MEMORY_INDEX_HARD_BUDGET_CHARS);
3365
+ const lastNewline = clipped.lastIndexOf("\n");
3366
+ 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.`;
3367
+ }
3368
+ /**
3369
+ * Read the agent's hand-maintained shared index at `.memory/MEMORY.md`.
3370
+ * Returns the trimmed contents, or `null` when the file is absent or empty —
3371
+ * the agent owns this file, so we surface exactly what it wrote.
3372
+ */
3373
+ async function readMemoryIndexFile({ cwd }) {
3374
+ const path = join(cwd, MEMORY_DIRNAME, MEMORY_INDEX_FILENAME);
3375
+ try {
3376
+ const trimmed = (await readFile(path, "utf-8")).trim();
3377
+ return trimmed.length > 0 ? trimmed : null;
3378
+ } catch {
3379
+ return null;
3380
+ }
3381
+ }
2290
3382
  const cache = /* @__PURE__ */ new Map();
2291
3383
  /**
3384
+ * Build the derived per-person index for a single user.
3385
+ *
2292
3386
  * Returns:
2293
- * - `null` if `.memory/` doesn't exist
2294
- * - `""` if the dir exists but contains nothing in the requested scope
3387
+ * - `null` if `.memory/users/` doesn't exist
3388
+ * - `""` if it exists but this user has no memory
2295
3389
  * - rendered markdown body (no surrounding header — caller wraps)
2296
3390
  *
2297
- * The mtime-keyed cache stores the raw walked entries (the cost is the FS
2298
- * walk); filtering by scope is cheap and runs per call, so two turns with
2299
- * different scopes on the same cwd render correctly from one cached walk.
3391
+ * Scoping by id keeps one person's memory from bleeding into another's
3392
+ * conversation. The mtime-keyed cache stores the raw walked entries (the cost
3393
+ * is the FS walk); filtering by user is cheap and runs per call, so two turns
3394
+ * with different users on the same cwd render correctly from one cached walk.
2300
3395
  */
2301
- async function buildMemoryIndex({ cwd, scope }) {
2302
- const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
2303
- const maxMtimeMs = await maxMtimeAcrossDir(memoryDirAbs);
3396
+ async function buildMemoryIndex({ cwd, userId }) {
3397
+ const usersDirAbs = join(cwd, MEMORY_DIRNAME, USERS_DIRNAME);
3398
+ const maxMtimeMs = await maxMtimeAcrossDir(usersDirAbs);
2304
3399
  if (maxMtimeMs === null) {
2305
3400
  cache.delete(cwd);
2306
3401
  return null;
@@ -2308,13 +3403,13 @@ async function buildMemoryIndex({ cwd, scope }) {
2308
3403
  let cached = cache.get(cwd);
2309
3404
  if (!cached || cached.builtAtMs < maxMtimeMs) {
2310
3405
  cached = {
2311
- entries: await collectEntries(memoryDirAbs, cwd),
3406
+ entries: await collectUserEntries(usersDirAbs, cwd),
2312
3407
  builtAtMs: Date.now()
2313
3408
  };
2314
3409
  cache.set(cwd, cached);
2315
3410
  }
2316
- const visible = cached.entries.filter((entry) => scope.kind === "user" ? entry.type === "users" && entry.subject?.startsWith(scope.userId) === true : entry.type !== "users");
2317
- return visible.length === 0 ? "" : renderIndex(visible);
3411
+ const visible = cached.entries.filter((entry) => entry.subject.startsWith(userId));
3412
+ return visible.length === 0 ? "" : renderUserIndex(visible);
2318
3413
  }
2319
3414
  async function maxMtimeAcrossDir(dir) {
2320
3415
  let dirStat;
@@ -2362,45 +3457,22 @@ async function listSubdirs(dir) {
2362
3457
  }
2363
3458
  return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
2364
3459
  }
2365
- async function collectEntries(rootDirAbs, cwd) {
2366
- const collected = [];
2367
- await Promise.all(TYPE_DIRS.map(async (type) => {
2368
- const typeDirAbs = join(rootDirAbs, type);
2369
- if (TYPES_WITH_SUBJECT.has(type)) {
2370
- const subjectDirs = await listSubdirs(typeDirAbs);
2371
- await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
2372
- const subject = basename(subjectDirAbs);
2373
- const files = await listMdFilesShallow(subjectDirAbs);
2374
- const parsed = await Promise.all(files.map(async (file) => {
2375
- const fm = await readFrontmatterOnly(file);
2376
- if (!fm?.name || !fm?.description) return null;
2377
- return {
2378
- name: fm.name,
2379
- description: fm.description,
2380
- type,
2381
- subject,
2382
- relPath: relative(cwd, file)
2383
- };
2384
- }));
2385
- for (const e of parsed) if (e) collected.push(e);
2386
- }));
2387
- } else {
2388
- const files = await listMdFilesShallow(typeDirAbs);
2389
- const parsed = await Promise.all(files.map(async (file) => {
2390
- const fm = await readFrontmatterOnly(file);
2391
- if (!fm?.name || !fm?.description) return null;
2392
- return {
2393
- name: fm.name,
2394
- description: fm.description,
2395
- type,
2396
- subject: null,
2397
- relPath: relative(cwd, file)
2398
- };
2399
- }));
2400
- for (const e of parsed) if (e) collected.push(e);
2401
- }
2402
- }));
2403
- return collected;
3460
+ async function collectUserEntries(usersDirAbs, cwd) {
3461
+ const subjectDirs = await listSubdirs(usersDirAbs);
3462
+ return (await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
3463
+ const subject = basename(subjectDirAbs);
3464
+ const files = await listMdFilesShallow(subjectDirAbs);
3465
+ return (await Promise.all(files.map(async (file) => {
3466
+ const fm = await readFrontmatterOnly(file);
3467
+ if (!fm?.name || !fm?.description) return null;
3468
+ return {
3469
+ name: fm.name,
3470
+ description: fm.description,
3471
+ subject,
3472
+ relPath: relative(cwd, file)
3473
+ };
3474
+ }))).filter((e) => e !== null);
3475
+ }))).flat();
2404
3476
  }
2405
3477
  async function readFrontmatterOnly(filePath) {
2406
3478
  let fh;
@@ -2431,77 +3503,189 @@ function parseFrontmatter(text) {
2431
3503
  const result = FrontmatterSchema.safeParse(parsed);
2432
3504
  return result.success ? result.data : null;
2433
3505
  }
2434
- function renderIndex(entries) {
2435
- const byType = {
2436
- users: [],
2437
- projects: [],
2438
- feedback: [],
2439
- reference: []
2440
- };
2441
- for (const e of entries) byType[e.type].push(e);
3506
+ function renderUserIndex(entries) {
3507
+ const bySubject = /* @__PURE__ */ new Map();
3508
+ for (const e of entries) {
3509
+ const list = bySubject.get(e.subject) ?? [];
3510
+ list.push(e);
3511
+ bySubject.set(e.subject, list);
3512
+ }
3513
+ const lines = ["### Users"];
3514
+ for (const subject of [...bySubject.keys()].sort()) {
3515
+ lines.push(`- **${subject}**`);
3516
+ for (const e of bySubject.get(subject) ?? []) lines.push(` - \`${e.relPath}\` — ${e.description}`);
3517
+ }
3518
+ return lines.join("\n");
3519
+ }
3520
+ /**
3521
+ * One-time migration for agents created before `MEMORY.md` existed. If there is
3522
+ * no hand-maintained `.memory/MEMORY.md` yet but the agent has shared memory
3523
+ * files from the old frontmatter-indexed layout (`projects/`, `feedback/`,
3524
+ * `reference/`), derive a `MEMORY.md` from their frontmatter and write it once.
3525
+ * After that the agent owns the file — this never runs again for that agent and
3526
+ * never clobbers an existing index.
3527
+ *
3528
+ * Returns the seeded contents (also written to disk), or `null` when nothing
3529
+ * was seeded (index already present, or no legacy shared files). A write
3530
+ * failure propagates so the caller can log it; the read path then falls back to
3531
+ * whatever is on disk.
3532
+ */
3533
+ async function seedMemoryIndexFile({ cwd }) {
3534
+ const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
3535
+ const indexPath = join(memoryDirAbs, MEMORY_INDEX_FILENAME);
3536
+ if (await stat(indexPath).catch(() => null)) return null;
3537
+ const perType = await Promise.all(LEGACY_SHARED_TYPES.map(async ({ dir, label }) => {
3538
+ const typeDirAbs = join(memoryDirAbs, dir);
3539
+ const files = [];
3540
+ await walkMdFiles(typeDirAbs, files);
3541
+ return {
3542
+ label,
3543
+ entries: (await Promise.all(files.map(async (file) => {
3544
+ const fm = await readFrontmatterOnly(file);
3545
+ if (!fm?.name || !fm?.description) return null;
3546
+ return {
3547
+ name: fm.name,
3548
+ description: fm.description,
3549
+ relPath: relative(cwd, file)
3550
+ };
3551
+ }))).filter((e) => !!e)
3552
+ };
3553
+ }));
3554
+ if (perType.every((group) => group.entries.length === 0)) return null;
3555
+ const content = renderSeededIndex(perType);
3556
+ await writeFile(indexPath, `${content}\n`, "utf-8");
3557
+ return content;
3558
+ }
3559
+ function renderSeededIndex(groups) {
2442
3560
  const sections = [];
2443
- for (const type of TYPE_DIRS) {
2444
- const items = byType[type];
2445
- if (items.length === 0) continue;
2446
- sections.push(`### ${TYPE_LABELS[type]}`);
2447
- if (TYPES_WITH_SUBJECT.has(type)) {
2448
- const bySubject = /* @__PURE__ */ new Map();
2449
- for (const e of items) {
2450
- const subject = e.subject ?? "(unknown)";
2451
- const list = bySubject.get(subject) ?? [];
2452
- list.push(e);
2453
- bySubject.set(subject, list);
2454
- }
2455
- const subjects = [...bySubject.keys()].sort();
2456
- for (const subject of subjects) {
2457
- sections.push(`- **${subject}**`);
2458
- for (const e of bySubject.get(subject) ?? []) sections.push(` - \`${e.relPath}\` — ${e.description}`);
2459
- }
2460
- } else for (const e of items) sections.push(`- \`${e.relPath}\` — ${e.description}`);
3561
+ for (const { label, entries } of groups) {
3562
+ if (entries.length === 0) continue;
3563
+ sections.push(`### ${label}`);
3564
+ const sorted = [...entries].sort((a, b) => a.relPath.localeCompare(b.relPath));
3565
+ for (const e of sorted) sections.push(`- [${e.name}](${e.relPath}) — ${e.description}`);
2461
3566
  sections.push("");
2462
3567
  }
2463
3568
  return sections.join("\n").trimEnd();
2464
3569
  }
3570
+ /**
3571
+ * The version at which the hand-maintained `MEMORY.md` layout was introduced.
3572
+ * Used to classify an unversioned `.memory/`: if it already has a `MEMORY.md`
3573
+ * it's on this layout (not the pre-MEMORY.md v1 frontmatter layout), so it
3574
+ * shouldn't be treated as v1 and re-seeded.
3575
+ */
3576
+ const HAND_MAINTAINED_INDEX_VERSION = 2;
3577
+ const VERSION_FILENAME = ".version";
3578
+ const MEMORY_MIGRATIONS = [{
3579
+ from: 1,
3580
+ to: 2,
3581
+ apply: async ({ cwd }) => {
3582
+ await seedMemoryIndexFile({ cwd });
3583
+ }
3584
+ }];
3585
+ /**
3586
+ * The layout version of an agent's `.memory/`:
3587
+ * - `null` when there's no `.memory/` at all (a fresh agent is current by
3588
+ * construction; nothing to migrate).
3589
+ * - `1` when `.memory/` exists but carries no `.version` marker — i.e. it
3590
+ * predates versioning.
3591
+ * - otherwise the integer in `.memory/.version`.
3592
+ */
3593
+ async function readMemoryVersion(cwd) {
3594
+ const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
3595
+ if (!(await stat(memoryDirAbs).catch(() => null))?.isDirectory()) return null;
3596
+ const raw = await readFile(join(memoryDirAbs, VERSION_FILENAME), "utf-8").catch(() => null);
3597
+ if (raw === null) return await stat(join(memoryDirAbs, MEMORY_INDEX_FILENAME)).then((s) => s.isFile()).catch(() => false) ? HAND_MAINTAINED_INDEX_VERSION : 1;
3598
+ const parsed = Number.parseInt(raw.trim(), 10);
3599
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
3600
+ }
3601
+ async function writeMemoryVersion(cwd, version) {
3602
+ await writeFile(join(cwd, MEMORY_DIRNAME, VERSION_FILENAME), `${version}\n`, "utf-8");
3603
+ }
3604
+ /**
3605
+ * Bring an agent's `.memory/` up to `CURRENT_MEMORY_VERSION` by applying the
3606
+ * ordered migrations. Runs on session start. No-op when there's no `.memory/`
3607
+ * yet or it's already current. Migrations must be idempotent, so a lost/unwritten
3608
+ * version marker (the file isn't committed by the harness) only costs a repeated
3609
+ * no-op, never corruption. Returns the `{ from, to }` actually applied, or
3610
+ * `null` when nothing ran.
3611
+ */
3612
+ async function migrateMemory({ cwd }) {
3613
+ const from = await readMemoryVersion(cwd);
3614
+ if (from === null || from >= 2) return null;
3615
+ let version = from;
3616
+ while (version < 2) {
3617
+ const migration = MEMORY_MIGRATIONS.find((m) => m.from === version);
3618
+ if (!migration) break;
3619
+ await migration.apply({ cwd });
3620
+ version = migration.to;
3621
+ }
3622
+ await writeMemoryVersion(cwd, version);
3623
+ return {
3624
+ from,
3625
+ to: version
3626
+ };
3627
+ }
2465
3628
  //#endregion
2466
3629
  //#region src/extensions/memory.ts
2467
- const log$6 = logger.child({ module: "memory-extension" });
3630
+ const log$5 = logger.child({ module: "memory-extension" });
2468
3631
  /**
2469
3632
  * The standing instructions for the memory system. Always injected (even with
2470
- * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
3633
+ * no `MEMORY.md`) so the agent knows it can persist notes and how. `users/` is
2471
3634
  * described by the platform memory extension, which is the only thing that can
2472
3635
  * scope it to a person — here we just point at it.
2473
3636
  */
2474
3637
  function memoryInstructions(cwd) {
2475
3638
  return `## Memory across conversations
2476
3639
 
2477
- 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.
3640
+ 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.
2478
3641
 
2479
3642
  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.
2480
3643
 
2481
- 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.`;
3644
+ 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\`.)
3645
+
3646
+ 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:
3647
+ - 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.
3648
+ - 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.
3649
+ - 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.
3650
+ - 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.
3651
+
3652
+ Commit and push after editing \`.memory/\` to persist it.`;
2482
3653
  }
2483
3654
  function composeBlock$1({ cwd, index }) {
2484
3655
  const instructions = memoryInstructions(cwd);
2485
3656
  if (!index || index.length === 0) return instructions;
2486
- return `${instructions}\n\n## Memory index\n\n${index}`;
3657
+ const header = `## Memory index (${indexSizeNote(index)})`;
3658
+ const warning = indexBudgetWarning(index);
3659
+ const rendered = enforceMemoryIndexHardBudget(index);
3660
+ return `${instructions}\n\n${warning ? `${header}\n\n${warning}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
2487
3661
  }
2488
3662
  const memoryExtension = (pi) => {
2489
3663
  let cachedBlock = null;
2490
3664
  pi.on("session_start", async (_event, ctx) => {
2491
3665
  try {
2492
- const index = await buildMemoryIndex({
2493
- cwd: ctx.cwd,
2494
- scope: { kind: "shared" }
2495
- });
3666
+ const migrated = await migrateMemory({ cwd: ctx.cwd });
3667
+ if (migrated !== null) log$5.info({
3668
+ event: "memory_migrated",
3669
+ from: migrated.from,
3670
+ to: migrated.to
3671
+ }, "migrated memory layout to current version");
3672
+ } catch (err) {
3673
+ log$5.warn({
3674
+ err,
3675
+ event: "memory_migration_failed"
3676
+ }, "memory migration failed; continuing with existing index");
3677
+ }
3678
+ try {
3679
+ const index = await readMemoryIndexFile({ cwd: ctx.cwd });
2496
3680
  cachedBlock = composeBlock$1({
2497
3681
  cwd: ctx.cwd,
2498
3682
  index
2499
3683
  });
2500
3684
  } catch (err) {
2501
- log$6.warn({
3685
+ log$5.warn({
2502
3686
  err,
2503
3687
  event: "memory_index_failed"
2504
- }, "memory index build failed; injecting instructions only");
3688
+ }, "memory index read failed; injecting instructions only");
2505
3689
  cachedBlock = memoryInstructions(ctx.cwd);
2506
3690
  }
2507
3691
  });
@@ -2512,7 +3696,7 @@ const memoryExtension = (pi) => {
2512
3696
  };
2513
3697
  //#endregion
2514
3698
  //#region src/extensions/platform-memory.ts
2515
- const log$5 = logger.child({ module: "platform-memory-extension" });
3699
+ const log$4 = logger.child({ module: "platform-memory-extension" });
2516
3700
  /**
2517
3701
  * Resolve the human on this turn via the API, keyed by the message id.
2518
3702
  * `/sandbox/channel-context` only returns a sender for a platform-known
@@ -2525,13 +3709,13 @@ const log$5 = logger.child({ module: "platform-memory-extension" });
2525
3709
  async function resolveTurnUser(messageId) {
2526
3710
  const client = sandboxClient();
2527
3711
  if (!client) {
2528
- log$5.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
3712
+ log$4.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2529
3713
  return null;
2530
3714
  }
2531
3715
  try {
2532
3716
  const res = await client["channel-context"].$get({ query: { messageId } });
2533
3717
  if (!res.ok) {
2534
- log$5.warn({
3718
+ log$4.warn({
2535
3719
  event: "resolve_turn_user_failed",
2536
3720
  status: res.status
2537
3721
  }, "channel-context returned non-ok; withholding user memory");
@@ -2544,7 +3728,7 @@ async function resolveTurnUser(messageId) {
2544
3728
  displayName: sender.displayName
2545
3729
  };
2546
3730
  } catch (err) {
2547
- log$5.warn({
3731
+ log$4.warn({
2548
3732
  err,
2549
3733
  event: "resolve_turn_user_failed"
2550
3734
  }, "failed to resolve current user; withholding user memory");
@@ -2560,9 +3744,12 @@ function slugifyName(name) {
2560
3744
  function composeBlock({ index, user }) {
2561
3745
  const instructions = `## Current user memory
2562
3746
 
2563
- 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.`;
3747
+ 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.`;
2564
3748
  if (!index || index.length === 0) return instructions;
2565
- return `${instructions}\n\n${index}`;
3749
+ const warning = indexBudgetWarning(index);
3750
+ const header = `Memory index (${indexSizeNote(index)}):`;
3751
+ const rendered = enforceMemoryIndexHardBudget(index);
3752
+ return `${instructions}\n\n${warning ? `${warning}\n\n${header}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
2566
3753
  }
2567
3754
  /**
2568
3755
  * Build the platform memory extension. `channelContext` is the per-turn ref
@@ -2583,15 +3770,12 @@ function createPlatformMemoryExtension({ channelContext }) {
2583
3770
  cachedBlock = composeBlock({
2584
3771
  index: await buildMemoryIndex({
2585
3772
  cwd: ctx.cwd,
2586
- scope: {
2587
- kind: "user",
2588
- userId: user.id
2589
- }
3773
+ userId: user.id
2590
3774
  }),
2591
3775
  user
2592
3776
  });
2593
3777
  } catch (err) {
2594
- log$5.warn({
3778
+ log$4.warn({
2595
3779
  err,
2596
3780
  event: "user_memory_index_failed"
2597
3781
  }, "user memory index build failed; skipping injection");
@@ -2606,7 +3790,7 @@ function createPlatformMemoryExtension({ channelContext }) {
2606
3790
  }
2607
3791
  //#endregion
2608
3792
  //#region src/extensions/self-trace.ts
2609
- const log$4 = logger.child({ module: "self-trace-extension" });
3793
+ const log$3 = logger.child({ module: "self-trace-extension" });
2610
3794
  /**
2611
3795
  * Reports the agent's own execution as OpenTelemetry spans:
2612
3796
  * agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
@@ -2631,7 +3815,7 @@ const selfTraceExtension = (pi) => {
2631
3815
  sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
2632
3816
  sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
2633
3817
  const sc = sessionSpan.spanContext();
2634
- log$4.info({
3818
+ log$3.info({
2635
3819
  event: "self_trace_session_start",
2636
3820
  trace_id: sc.traceId,
2637
3821
  span_id: sc.spanId,
@@ -2743,13 +3927,13 @@ const selfTraceExtension = (pi) => {
2743
3927
  * Lives in the harness package — soul.md is content from the agent's
2744
3928
  * own git repo, not from the platform — so its handling stays here.
2745
3929
  */
2746
- const log$3 = logger.child({ module: "soul-extension" });
3930
+ const log$2 = logger.child({ module: "soul-extension" });
2747
3931
  async function readSoul(cwd) {
2748
3932
  try {
2749
3933
  return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
2750
3934
  } catch (err) {
2751
3935
  if (err?.code === "ENOENT") return null;
2752
- log$3.warn({
3936
+ log$2.warn({
2753
3937
  err,
2754
3938
  event: "soul_read_failed"
2755
3939
  }, "soul.md read failed");
@@ -2781,8 +3965,7 @@ const soulExtension = (pi) => {
2781
3965
  };
2782
3966
  //#endregion
2783
3967
  //#region src/extensions/subagent/index.ts
2784
- const log$2 = logger.child({ module: "subagent-ext" });
2785
- const COLD_START_FLAG_WAIT_MS = 750;
3968
+ const log$1 = logger.child({ module: "subagent-ext" });
2786
3969
  const MAX_TASKS = 8;
2787
3970
  const TaskItem = Type.Object({
2788
3971
  task: Type.String({ description: "The task to delegate to a subagent run." }),
@@ -2791,8 +3974,14 @@ const TaskItem = Type.Object({
2791
3974
  maxLength: 120
2792
3975
  }),
2793
3976
  persona: Type.Optional(Type.String({ description: "Optional extra system prompt / role for this task, applied ON TOP of the child run's own default persona (your full identity and soul are still there underneath). Omit to run with just your default persona." })),
2794
- model: Type.Optional(Type.String({ description: "Optional model id to run this subagent on (e.g. \"anthropic/claude-opus-4-8\"). Must be a real catalogued model. Omit to run on your own model. If you are locked to a Google-compliant model, only compliant models are accepted." }))
3977
+ 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." })),
3978
+ timeoutMinutes: Type.Optional(Type.Integer({
3979
+ 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.",
3980
+ minimum: 1,
3981
+ maximum: 360
3982
+ }))
2795
3983
  });
3984
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2796
3985
  const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2797
3986
  description: "One or more tasks to delegate. Each spawns an isolated subagent run linked to this conversation; they run in parallel and each rewakes you with its result when it finishes.",
2798
3987
  minItems: 1,
@@ -2807,7 +3996,9 @@ function buildTool(messageId) {
2807
3996
  "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2808
3997
  "Use it to parallelize independent work, to keep a large or noisy subtask out of your own context, or to run a task under a specialized persona.",
2809
3998
  "Fire-and-forget: this returns immediately after queueing. It does NOT wait for results. Each subagent runs on its own and, when it finishes, sends you its result on this thread — so queue the work, then keep going or end your turn. To chain, re-delegate after a result lands.",
2810
- "Pass tasks: [{ task, title, persona?, model? }]. title is a short 3-6 word name for the task — it is shown to the person in the chat as that subagent's row, so name the work rather than restating the prompt. persona is an optional extra system prompt layered ON TOP of your default persona for that task (it adds to, it does not replace, your identity); omit it to run with just your default persona. model is an optional model id for that task; omit it to run on your own model."
3999
+ "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.",
4000
+ "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.",
4001
+ "Steering: to add context, correct course, or answer a question a subagent needs mid-run, post to its conversation with `platform conversations post <conversationId> --message \"...\"`. If the subagent is still running, your message lands as a live steer picked up in that same turn; if it has gone idle, it queues as its next turn. This is the same primitive as any conversation message — there is no separate steer channel."
2811
4002
  ].join(" "),
2812
4003
  promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2813
4004
  parameters: SubagentParams,
@@ -2825,28 +4016,39 @@ function buildTool(messageId) {
2825
4016
  task: t.task,
2826
4017
  title: t.title ?? null,
2827
4018
  persona: t.persona ?? null,
2828
- model: t.model ?? null
4019
+ model: t.model ?? null,
4020
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2829
4021
  }));
2830
4022
  try {
2831
- const { taskIds } = await postSubagentSpawn({
4023
+ const spawned = await postSubagentSpawn({
2832
4024
  messageId,
2833
4025
  tasks: spawnTasks
2834
4026
  });
2835
- log$2.info({
4027
+ const { taskIds } = spawned;
4028
+ log$1.info({
2836
4029
  event: "subagent_spawned",
2837
4030
  count: taskIds.length
2838
4031
  }, "subagent tasks queued");
2839
- const lines = taskIds.map((id, i) => `- ${id}: ${spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? ""}`).join("\n");
4032
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
4033
+ const lines = taskIds.map((id, i) => {
4034
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
4035
+ const conv = convByTask.get(id);
4036
+ return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
4037
+ }).join("\n");
4038
+ const peerHint = spawned.tasks.length ? "\nEach subagent runs on its own conversation (id shown per task above). To SEE what one is doing while it runs, read it with `platform conversations show <conversationId>`. To STEER one mid-run — add context, correct course, answer a question — post to its conversation with `platform conversations post <conversationId> --message \"...\"`; it lands as a live steer if the subagent is still running, or as its next turn if it has gone idle." : "";
2840
4039
  return {
2841
4040
  content: [{
2842
4041
  type: "text",
2843
- text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}`
4042
+ text: `Queued ${taskIds.length} subagent ${taskIds.length === 1 ? "run" : "runs"}. Each runs on its own and will send you its result on this thread when it finishes — keep working or end your turn meanwhile.\n${lines}${peerHint}`
2844
4043
  }],
2845
- details: { taskIds }
4044
+ details: {
4045
+ taskIds,
4046
+ tasks: spawned.tasks
4047
+ }
2846
4048
  };
2847
4049
  } catch (err) {
2848
4050
  const message = err instanceof Error ? err.message : String(err);
2849
- log$2.warn({
4051
+ log$1.warn({
2850
4052
  err,
2851
4053
  event: "subagent_spawn_failed"
2852
4054
  }, "subagent spawn failed");
@@ -2863,29 +4065,24 @@ function buildTool(messageId) {
2863
4065
  };
2864
4066
  }
2865
4067
  /**
2866
- * Gated on `harness-subagent-enabled`, read from the shared feature-flag poll.
2867
4068
  * The factory takes the session's channel context to resolve the originating
2868
4069
  * messageId — the api links each spawned run to the conversation that message
2869
4070
  * belongs to and rewakes it on completion (nothing about the parent is piped
2870
- * from the sandbox beyond that id).
4071
+ * from the sandbox beyond that id). The tool is registered unconditionally at
4072
+ * session_start.
2871
4073
  */
2872
4074
  function createSubagentExtension({ channelContext }) {
2873
4075
  return (pi) => {
2874
4076
  const messageId = extractMessageId(channelContext);
2875
- startFeatureFlagPoller();
2876
4077
  let registered = false;
2877
4078
  const registerOnce = () => {
2878
4079
  if (registered) return;
2879
4080
  registered = true;
2880
4081
  pi.registerTool(buildTool(messageId));
2881
- log$2.info({ event: "subagent_enabled" }, "subagent tool registered");
4082
+ log$1.info({ event: "subagent_enabled" }, "subagent tool registered");
2882
4083
  };
2883
- onFlagChange("subagent", (enabled) => {
2884
- if (enabled) registerOnce();
2885
- });
2886
- pi.on("session_start", async () => {
2887
- if (getPolledFlag("subagent") === null) await Promise.race([awaitFirstFlagPoll(), new Promise((resolve) => setTimeout(resolve, COLD_START_FLAG_WAIT_MS).unref?.())]);
2888
- if (getPolledFlag("subagent") === true) registerOnce();
4084
+ pi.on("session_start", () => {
4085
+ registerOnce();
2889
4086
  });
2890
4087
  };
2891
4088
  }
@@ -2916,214 +4113,6 @@ const toolCallEnvExtension = (pi) => {
2916
4113
  });
2917
4114
  };
2918
4115
  //#endregion
2919
- //#region src/extensions/tool-call-summary.ts
2920
- const log$1 = logger.child({ module: "tool-call-summary-extension" });
2921
- /**
2922
- * The injected parameter name: a namespaced sentinel, so it can never collide
2923
- * with a real tool argument and is unmistakable in transcripts and logs. The
2924
- * frontend renderer (ANY-2723) duplicates this literal — keep the two in sync.
2925
- */
2926
- const TOOL_CALL_SUMMARY_FIELD = "__skydive_summary__";
2927
- /** JSON Schema fragment for the injected parameter. */
2928
- const SUMMARY_PROPERTY = {
2929
- type: "string",
2930
- description: "Required for every tool call. A concise, specific summary (max ~8 words) of what THIS call does and why, written for a person watching the conversation, e.g. \"Searching feedback for billing complaints\" or \"Reading the auth middleware\". Address the user directly in second person: the summary is read by the user, so refer to their things as \"your\", never in third person — \"Reading your emails\", not \"Reading his emails\". Always use the present progressive tense, since it is shown while the call runs: \"Updating your Slack\", never \"Updated your Slack\". Make each summary distinct from your other tool calls; never reuse a generic label like \"Search query\" or \"Running command\"."
2931
- };
2932
- const jsonSchemaObjectSchema = z.object({
2933
- type: z.unknown().optional(),
2934
- properties: z.record(z.string(), z.unknown()).optional(),
2935
- required: z.array(z.string()).optional(),
2936
- additionalProperties: z.unknown().optional()
2937
- }).passthrough();
2938
- const toolEntrySchema = z.object({
2939
- name: z.string().optional(),
2940
- input_schema: jsonSchemaObjectSchema.optional(),
2941
- parameters: jsonSchemaObjectSchema.optional(),
2942
- function: z.object({
2943
- name: z.string().optional(),
2944
- parameters: jsonSchemaObjectSchema.optional()
2945
- }).passthrough().optional()
2946
- }).passthrough();
2947
- const payloadWithToolsSchema = z.object({ tools: z.array(z.unknown()) }).passthrough();
2948
- /**
2949
- * Add the summary property to one JSON Schema object. Returns the augmented
2950
- * copy, or `null` when the tool should be left untouched: a strict schema
2951
- * (`additionalProperties: false`) whose validation would reject the extra
2952
- * field, or one that already declares a `__skydive_summary__` property of its own.
2953
- */
2954
- function augmentSchema(schema) {
2955
- if (schema.additionalProperties === false) return null;
2956
- const properties = schema.properties ?? {};
2957
- if ("__skydive_summary__" in properties) return null;
2958
- const required = schema.required ?? [];
2959
- return {
2960
- ...schema,
2961
- type: schema.type ?? "object",
2962
- properties: {
2963
- [TOOL_CALL_SUMMARY_FIELD]: SUMMARY_PROPERTY,
2964
- ...properties
2965
- },
2966
- required: required.includes("__skydive_summary__") ? required : [...required, TOOL_CALL_SUMMARY_FIELD]
2967
- };
2968
- }
2969
- /**
2970
- * Augment a single tool entry, dispatching on which provider shape it is.
2971
- * Returns the (possibly rebuilt) entry and whether anything changed. Skipped
2972
- * tools — wrong shape, strict, or name in `strictToolNames` — return unchanged.
2973
- */
2974
- function augmentToolEntry(entry, strictToolNames) {
2975
- const parsed = toolEntrySchema.safeParse(entry);
2976
- if (!parsed.success) return {
2977
- entry,
2978
- changed: false
2979
- };
2980
- const tool = parsed.data;
2981
- const name = tool.name ?? tool.function?.name ?? null;
2982
- if (name !== null && strictToolNames.has(name)) return {
2983
- entry,
2984
- changed: false
2985
- };
2986
- if (tool.input_schema) {
2987
- const augmented = augmentSchema(tool.input_schema);
2988
- if (!augmented) return {
2989
- entry,
2990
- changed: false
2991
- };
2992
- return {
2993
- entry: {
2994
- ...tool,
2995
- input_schema: augmented
2996
- },
2997
- changed: true
2998
- };
2999
- }
3000
- if (tool.parameters) {
3001
- const augmented = augmentSchema(tool.parameters);
3002
- if (!augmented) return {
3003
- entry,
3004
- changed: false
3005
- };
3006
- return {
3007
- entry: {
3008
- ...tool,
3009
- parameters: augmented
3010
- },
3011
- changed: true
3012
- };
3013
- }
3014
- if (tool.function?.parameters) {
3015
- const augmented = augmentSchema(tool.function.parameters);
3016
- if (!augmented) return {
3017
- entry,
3018
- changed: false
3019
- };
3020
- return {
3021
- entry: {
3022
- ...tool,
3023
- function: {
3024
- ...tool.function,
3025
- parameters: augmented
3026
- }
3027
- },
3028
- changed: true
3029
- };
3030
- }
3031
- return {
3032
- entry,
3033
- changed: false
3034
- };
3035
- }
3036
- /**
3037
- * Inject the summary field into every eligible tool in a provider payload.
3038
- * Returns a new payload when at least one tool was augmented, or `undefined`
3039
- * to signal "no change" (which keeps the original payload, per the
3040
- * `before_provider_request` contract).
3041
- *
3042
- * @param payload The outgoing provider payload (shape varies by provider).
3043
- * @param strictToolNames Names of tools whose registered schema is strict and
3044
- * must be skipped to avoid validation errors.
3045
- */
3046
- function injectToolCallSummary(payload, strictToolNames) {
3047
- const parsed = payloadWithToolsSchema.safeParse(payload);
3048
- if (!parsed.success || parsed.data.tools.length === 0) return void 0;
3049
- let changed = false;
3050
- const tools = parsed.data.tools.map((entry) => {
3051
- const result = augmentToolEntry(entry, strictToolNames);
3052
- if (result.changed) changed = true;
3053
- return result.entry;
3054
- });
3055
- if (!changed) return void 0;
3056
- return {
3057
- ...parsed.data,
3058
- tools
3059
- };
3060
- }
3061
- /**
3062
- * Names of registered tools whose schema sets `additionalProperties: false`.
3063
- * Pi validates the model's tool args against this registered schema, so the
3064
- * injected field would make a strict tool's call fail validation — skip them.
3065
- */
3066
- function getStrictToolNames(pi) {
3067
- const names = /* @__PURE__ */ new Set();
3068
- for (const tool of pi.getAllTools()) {
3069
- const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
3070
- if (parsed.success && parsed.data.additionalProperties === false) names.add(tool.name);
3071
- }
3072
- return names;
3073
- }
3074
- function toolDeclaresSummaryParam(pi, toolName) {
3075
- const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
3076
- if (!tool) return false;
3077
- const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
3078
- return parsed.success && parsed.data.properties != null && "__skydive_summary__" in parsed.data.properties;
3079
- }
3080
- /**
3081
- * Remove the injected summary from a tool's execution input. No-op when the
3082
- * field is absent, or when the tool genuinely declares a `__skydive_summary__`
3083
- * parameter of its own (which we never inject into, so its value is real).
3084
- * Mutates `input` in place, matching the `tool_call` contract.
3085
- *
3086
- * Fails open: this runs on the critical path of tool execution, and the
3087
- * `getAllTools()` lookup can throw. On any error we leave `input` untouched
3088
- * (the sentinel may pass through to the tool, but a bug here can never break
3089
- * tool execution).
3090
- */
3091
- function stripInjectedSummary(pi, toolName, input) {
3092
- try {
3093
- if (!("__skydive_summary__" in input)) return;
3094
- if (toolDeclaresSummaryParam(pi, toolName)) return;
3095
- delete input[TOOL_CALL_SUMMARY_FIELD];
3096
- } catch (err) {
3097
- log$1.error({
3098
- err,
3099
- event: "tool_call_summary_strip_failed",
3100
- toolName
3101
- }, "tool_call_summary strip failed; leaving tool input untouched");
3102
- }
3103
- }
3104
- /**
3105
- * Compute the rewritten payload for a `before_provider_request` event, failing
3106
- * open: on any error the original payload is left untouched so a bug here can
3107
- * never break an LLM call.
3108
- */
3109
- function buildInjectedPayload(pi, payload) {
3110
- try {
3111
- return injectToolCallSummary(payload, getStrictToolNames(pi));
3112
- } catch (err) {
3113
- log$1.error({
3114
- err,
3115
- event: "tool_call_summary_injection_failed"
3116
- }, "tool_call_summary injection failed; passing payload through unchanged");
3117
- return;
3118
- }
3119
- }
3120
- const toolCallSummaryExtension = (pi) => {
3121
- pi.on("before_provider_request", (event) => buildInjectedPayload(pi, event.payload));
3122
- pi.on("tool_call", (event) => {
3123
- stripInjectedSummary(pi, event.toolName, event.input);
3124
- });
3125
- };
3126
- //#endregion
3127
4116
  //#region src/extensions/background-tasks.ts
3128
4117
  /**
3129
4118
  * Background bash tasks as a pi extension.
@@ -3163,21 +4152,43 @@ const toolCallSummaryExtension = (pi) => {
3163
4152
  * from the origin messageId in its channel context (`resolveConversationFromApi`)
3164
4153
  * — and every run of the same conversation resolves to the same id, keeping the
3165
4154
  * shared map correctly scoped across turns. `bg_*`, the completion wake, and the
3166
- * next-session injection all filter to the resolved conversationan agent
3167
- * never sees or is woken by a task from a different chat. Only the output log
4155
+ * next-session injection all filter by a **scope key**the resolved
4156
+ * conversation id, or, when a session's conversation is unresolvable (a bare
4157
+ * CLI session, or a run whose channel-context ref carries no messageId), a
4158
+ * sentinel unique to that one session instance. Comparing on the raw
4159
+ * `conversationId` would bucket every unresolvable session together under
4160
+ * `null` and leak one's completion wake / status / next-session injection into
4161
+ * another; the sentinel keeps each isolated so an agent never sees or is woken
4162
+ * by a task from a different chat. Only the output log
3168
4163
  * spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
3169
4164
  * in memory; exit code and run state live on the in-memory task.
3170
4165
  *
3171
- * **No cross-restart survival (v1, deliberate).** Task state lives only in
3172
- * the running harness process. A harness restart (crash supervisord
3173
- * respawn, or `platform harness reload` after the agent edits its own
3174
- * harness) drops the map and pi's exec children are reaped with it. We don't
3175
- * resurrect from disk because the common next-run case cold-provisions a
3176
- * *different* sandbox anyway (warm reuse is the minority in prod), so on-disk
3177
- * state would rarely be the box the next run lands on. The idle-completion wake
3178
- * does cross the sandbox → platform boundary (a fresh run via `bg-task-done`),
3179
- * but a task whose harness dies before it finishes is gone — it is not
3180
- * resurrected, and this stays distinct from the scheduled-run (cron) system.
4166
+ * **Cross-restart survival is OPT-IN, via `bg_run({ resumable: true })`.**
4167
+ * A non-resumable task's state lives only in the running harness process: a
4168
+ * harness restart (crash → supervisord respawn, `platform harness reload`, or
4169
+ * a sandbox recycle on idle timeout / redeploy / template rebuild) drops the
4170
+ * map and pi's exec children are reaped with it, and the task is gone the
4171
+ * right behavior for a one-shot side-effecting command, which must never
4172
+ * silently re-run.
4173
+ *
4174
+ * A RESUMABLE task additionally checkpoints its *spec* (command, cwd, labels,
4175
+ * origin messageId not its live output/exit state) to a durable, api-side
4176
+ * journal keyed by conversation (`/sandbox/bg-task-journal`, redis). On the
4177
+ * NEXT run's `session_start` — which usually lands on a *different*,
4178
+ * cold-provisioned sandbox, which is exactly why the journal is api-side and
4179
+ * not on the sandbox disk — the harness lists the journal and relaunches any
4180
+ * spec it isn't already running, keeping the original id and prepending a
4181
+ * `<resumed-after-restart>` banner so the agent knows it re-ran from scratch,
4182
+ * not continued. The spec is dropped from the journal when the task
4183
+ * finishes/kills. Because relaunch RE-EXECUTES the command, resumable is only
4184
+ * for idempotent, long-lived work (pollers, watchers, retry loops); the tool
4185
+ * description enforces this and the default is false.
4186
+ *
4187
+ * The idle-completion wake (a task finishing while the agent is idle) crosses
4188
+ * the sandbox → platform boundary via a fresh run (`bg-task-done`) for both
4189
+ * resumable and non-resumable tasks; the journal is a separate, additive layer
4190
+ * that only handles a task whose harness dies BEFORE it finishes. This stays
4191
+ * distinct from the scheduled-run (cron) system.
3181
4192
  */
3182
4193
  const log = logger.child({ module: "background-tasks-ext" });
3183
4194
  const ops = createLocalBashOperations();
@@ -3188,6 +4199,7 @@ const WATCHDOG_INTERVAL_MS = 3e4;
3188
4199
  const KEEPALIVE_EVERY_MS = 6e4;
3189
4200
  const KEEPALIVE_MAX_MS = 3600 * 1e3;
3190
4201
  const STALL_HINT_AFTER_MS = 120 * 1e3;
4202
+ const PUBLISH_DEBOUNCE_MS = 300;
3191
4203
  const MAX_LOG_BYTES = 100 * 1024 * 1024;
3192
4204
  const DEFAULT_TAIL_LINES = 30;
3193
4205
  const TAIL_READ_BYTES = 64 * 1024;
@@ -3195,11 +4207,12 @@ function taskLabel(meta) {
3195
4207
  return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
3196
4208
  }
3197
4209
  let taskCounter = 0;
4210
+ let sessionScopeCounter = 0;
3198
4211
  const tasks = /* @__PURE__ */ new Map();
3199
4212
  let watchdogInterval = null;
3200
4213
  let lastKeepaliveAt = 0;
3201
- function sameConversation(meta, conversationId) {
3202
- return meta.conversationId === conversationId;
4214
+ function sameScope(meta, scopeKey) {
4215
+ return meta.scopeKey === scopeKey;
3203
4216
  }
3204
4217
  function logPath(id) {
3205
4218
  return join(tasksDir(), `${id}.log`);
@@ -3312,6 +4325,10 @@ function createBackgroundTasksExtension({ channelContext }) {
3312
4325
  return id;
3313
4326
  });
3314
4327
  }
4328
+ const unresolvedScopeSentinel = `unresolved:${process.pid.toString(36)}:${(sessionScopeCounter += 1).toString(36)}`;
4329
+ function scopeKey() {
4330
+ return conversationId ?? unresolvedScopeSentinel;
4331
+ }
3315
4332
  let agentActive = false;
3316
4333
  pi.on("agent_start", async () => {
3317
4334
  agentActive = true;
@@ -3339,13 +4356,16 @@ ${recentOutput}
3339
4356
  </background-task-finished>
3340
4357
  Run bg_logs for the full output.
3341
4358
 
3342
- This is a background-task completion, not a message from the user. If it needs no user-facing response — a routine or expected finish, a leftover or self-killed process, nothing the user must act on or would want to know right now — call \`platform channel suppress-reply\` and output nothing. Only send a message if the outcome changes what the user should do or know, or if you were explicitly waiting to report this result.`,
4359
+ This is a background-task completion, not a message from the user.
4360
+ For a routine or expected completion, output nothing.
4361
+ Only reply if the outcome changes what the user should know or do,
4362
+ or if you were explicitly waiting to report it.`,
3343
4363
  display: false
3344
4364
  };
3345
4365
  }
3346
4366
  async function notifyCompletion(meta) {
3347
4367
  if (meta.notified) return;
3348
- if (agentActive && sameConversation(meta, conversationId)) {
4368
+ if (agentActive && sameScope(meta, scopeKey())) {
3349
4369
  meta.notified = true;
3350
4370
  pi.sendMessage(await taskDoneMessage(meta), {
3351
4371
  triggerTurn: true,
@@ -3376,9 +4396,83 @@ This is a background-task completion, not a message from the user. If it needs n
3376
4396
  });
3377
4397
  } else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
3378
4398
  }
3379
- async function launchTask({ command, description, cwd }) {
4399
+ const SNAPSHOT_TAIL_LINES = 40;
4400
+ let lastPublishedSignature = null;
4401
+ let publishInFlight = null;
4402
+ let publishQueued = false;
4403
+ function snapshotSignature(snapshot) {
4404
+ return JSON.stringify(snapshot.map((t) => ({
4405
+ id: t.id,
4406
+ state: t.state,
4407
+ exitCode: t.exitCode,
4408
+ killedReason: t.killedReason,
4409
+ outputTail: t.outputTail
4410
+ })));
4411
+ }
4412
+ async function doPublishSnapshot() {
4413
+ if (!messageId) return;
4414
+ const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
4415
+ try {
4416
+ const snapshot = await Promise.all(mine.map(async (t) => ({
4417
+ id: t.id,
4418
+ command: t.command,
4419
+ description: t.description,
4420
+ startedAt: t.startedAt,
4421
+ state: t.running ? "running" : "finished",
4422
+ exitCode: t.exitCode,
4423
+ killedReason: t.killedReason,
4424
+ outputTail: await tailLog(t.id, SNAPSHOT_TAIL_LINES)
4425
+ })));
4426
+ const signature = snapshotSignature(snapshot);
4427
+ if (signature === lastPublishedSignature) return;
4428
+ lastPublishedSignature = signature;
4429
+ postBackgroundTasksSnapshot({
4430
+ messageId,
4431
+ tasks: snapshot
4432
+ });
4433
+ } catch (err) {
4434
+ log.debug({
4435
+ err,
4436
+ event: "bg_tasks_snapshot_build_failed"
4437
+ }, "building bg-tasks snapshot failed");
4438
+ }
4439
+ }
4440
+ async function publishSnapshotNow() {
4441
+ if (publishInFlight) {
4442
+ publishQueued = true;
4443
+ return;
4444
+ }
4445
+ publishInFlight = (async () => {
4446
+ try {
4447
+ do {
4448
+ publishQueued = false;
4449
+ await doPublishSnapshot();
4450
+ } while (publishQueued);
4451
+ } finally {
4452
+ publishInFlight = null;
4453
+ }
4454
+ })();
4455
+ await publishInFlight;
4456
+ }
4457
+ let publishTimer = null;
4458
+ function schedulePublishSnapshot() {
4459
+ if (publishTimer) return;
4460
+ publishTimer = setTimeout(() => {
4461
+ publishTimer = null;
4462
+ publishSnapshotNow();
4463
+ }, PUBLISH_DEBOUNCE_MS);
4464
+ publishTimer.unref?.();
4465
+ }
4466
+ async function flushPublishSnapshot() {
4467
+ if (publishTimer) {
4468
+ clearTimeout(publishTimer);
4469
+ publishTimer = null;
4470
+ }
4471
+ await publishSnapshotNow();
4472
+ }
4473
+ async function launchTask({ command, description, cwd, resumable = false, resumedFromJournal = false, id: providedId, startedAt: providedStartedAt }) {
3380
4474
  taskCounter += 1;
3381
- const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
4475
+ const id = providedId ?? `bg-${process.pid.toString(36)}-${taskCounter}`;
3382
4476
  try {
3383
4477
  await mkdir(tasksDir(), { recursive: true });
3384
4478
  } catch (err) {
@@ -3394,7 +4488,7 @@ This is a background-task completion, not a message from the user. If it needs n
3394
4488
  taskId: id
3395
4489
  }, "bg task log write failed");
3396
4490
  });
3397
- const startedAt = Date.now();
4491
+ const startedAt = providedStartedAt ?? Date.now();
3398
4492
  const meta = {
3399
4493
  id,
3400
4494
  command: stripPlatformExportsForDisplay(command),
@@ -3402,6 +4496,7 @@ This is a background-task completion, not a message from the user. If it needs n
3402
4496
  logBytes: 0,
3403
4497
  lastOutputAt: startedAt,
3404
4498
  conversationId,
4499
+ scopeKey: scopeKey(),
3405
4500
  messageId,
3406
4501
  description,
3407
4502
  notified: false,
@@ -3409,9 +4504,23 @@ This is a background-task completion, not a message from the user. If it needs n
3409
4504
  controller: new AbortController(),
3410
4505
  running: true,
3411
4506
  exitCode: null,
3412
- error: null
4507
+ error: null,
4508
+ resumable,
4509
+ cwd,
4510
+ resumedFromJournal
3413
4511
  };
3414
4512
  tasks.set(id, meta);
4513
+ if (resumable && messageId) putBackgroundTaskJournalSpec({
4514
+ messageId,
4515
+ spec: {
4516
+ id,
4517
+ command,
4518
+ cwd,
4519
+ description,
4520
+ startedAt,
4521
+ messageId
4522
+ }
4523
+ });
3415
4524
  ops.exec(command, cwd, {
3416
4525
  onData: (chunk) => {
3417
4526
  meta.logBytes += chunk.length;
@@ -3437,6 +4546,11 @@ This is a background-task completion, not a message from the user. If it needs n
3437
4546
  taskId: id,
3438
4547
  exitCode: meta.exitCode
3439
4548
  }, "bg task finished");
4549
+ if (meta.resumable && meta.messageId) deleteBackgroundTaskJournalSpec({
4550
+ messageId: meta.messageId,
4551
+ taskId: id
4552
+ });
4553
+ schedulePublishSnapshot();
3440
4554
  await notifyCompletion(meta);
3441
4555
  });
3442
4556
  ensureWatchdog();
@@ -3444,19 +4558,55 @@ This is a background-task completion, not a message from the user. If it needs n
3444
4558
  taskId: id,
3445
4559
  conversationId
3446
4560
  }, "bg task started");
4561
+ schedulePublishSnapshot();
3447
4562
  return meta;
3448
4563
  }
3449
4564
  function knownTaskIds() {
3450
- return [...tasks.values()].filter((t) => sameConversation(t, conversationId)).map((t) => t.id).join(", ") || "(none)";
4565
+ return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
4566
+ }
4567
+ let journalChecked = false;
4568
+ async function rehydrateJournaledTasks() {
4569
+ if (!messageId || journalChecked) return;
4570
+ journalChecked = true;
4571
+ const specs = await listBackgroundTaskJournalSpecs({ messageId });
4572
+ if (specs.length === 0) return;
4573
+ for (const spec of specs) {
4574
+ const live = tasks.get(spec.id);
4575
+ if (live && sameScope(live, scopeKey())) continue;
4576
+ log.info({
4577
+ taskId: spec.id,
4578
+ conversationId
4579
+ }, "relaunching journaled resumable bg task after restart");
4580
+ const meta = await launchTask({
4581
+ command: spec.command,
4582
+ description: spec.description,
4583
+ cwd: spec.cwd,
4584
+ resumable: true,
4585
+ resumedFromJournal: true,
4586
+ id: spec.id,
4587
+ startedAt: spec.startedAt
4588
+ });
4589
+ try {
4590
+ const stream = createWriteStream(logPath(meta.id), { flags: "a" });
4591
+ stream.write(`<resumed-after-restart>requeued and relaunched on a new sandbox after the previous one was drained; this is a fresh execution of the command from the start, not a continuation</resumed-after-restart>\n`);
4592
+ stream.end();
4593
+ } catch (err) {
4594
+ log.debug({
4595
+ err,
4596
+ taskId: meta.id
4597
+ }, "resume banner write failed");
4598
+ }
4599
+ }
3451
4600
  }
3452
4601
  pi.on("session_start", async () => {
3453
- if (tasks.size === 0) return;
4602
+ if (tasks.size === 0 && (!messageId || journalChecked)) return;
3454
4603
  await ensureConversationId();
3455
- for (const [id, meta] of tasks) if (sameConversation(meta, conversationId) && !meta.running && meta.notified) {
4604
+ await rehydrateJournaledTasks();
4605
+ for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
3456
4606
  tasks.delete(id);
3457
4607
  await unlink(logPath(id)).catch(() => {});
3458
4608
  }
3459
- const unnotified = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && !t.notified && !t.running);
4609
+ const unnotified = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && !t.notified && !t.running);
3460
4610
  for (const meta of unnotified) {
3461
4611
  meta.notified = true;
3462
4612
  pi.sendMessage(await taskDoneMessage(meta));
@@ -3465,7 +4615,9 @@ This is a background-task completion, not a message from the user. If it needs n
3465
4615
  conversationId,
3466
4616
  count: unnotified.length
3467
4617
  }, "injected completed bg tasks at session_start");
3468
- if ([...tasks.values()].some((t) => sameConversation(t, conversationId) && t.running)) ensureWatchdog();
4618
+ if ([...tasks.values()].some((t) => sameScope(t, scopeKey()) && t.running)) ensureWatchdog();
4619
+ lastPublishedSignature = null;
4620
+ await flushPublishSnapshot();
3469
4621
  });
3470
4622
  function err(text) {
3471
4623
  return {
@@ -3482,11 +4634,11 @@ This is a background-task completion, not a message from the user. If it needs n
3482
4634
  }
3483
4635
  function resolveTask(taskId) {
3484
4636
  const exact = tasks.get(taskId);
3485
- if (exact && sameConversation(exact, conversationId)) return {
4637
+ if (exact && sameScope(exact, scopeKey())) return {
3486
4638
  error: null,
3487
4639
  meta: exact
3488
4640
  };
3489
- const matches = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && t.id.startsWith(taskId));
4641
+ const matches = [...tasks.values()].filter((t) => sameScope(t, scopeKey()) && t.id.startsWith(taskId));
3490
4642
  if (matches.length === 1) return {
3491
4643
  error: null,
3492
4644
  meta: matches[0]
@@ -3495,7 +4647,7 @@ This is a background-task completion, not a message from the user. If it needs n
3495
4647
  return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
3496
4648
  }
3497
4649
  function listTasks() {
3498
- const mine = [...tasks.values()].filter((t) => sameConversation(t, conversationId));
4650
+ const mine = [...tasks.values()].filter((t) => sameScope(t, scopeKey()));
3499
4651
  if (mine.length === 0) return "No background tasks.";
3500
4652
  return mine.map((t) => {
3501
4653
  const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
@@ -3508,24 +4660,27 @@ This is a background-task completion, not a message from the user. If it needs n
3508
4660
  const bgRun = {
3509
4661
  name: "bg_run",
3510
4662
  label: "Run in background",
3511
- description: "Run a bash command in the background. Returns immediately with a task id; output streams to a log file. You are sent a message when it finishes — keep working or end your turn meanwhile. Use for anything over a couple of minutes (builds, batch jobs, retry loops, downloads). Inspect with bg_status / bg_logs, stop with bg_kill.",
3512
- promptSnippet: "bg_run — run a long command without blocking; you are notified on completion",
4663
+ description: "Run a bash command in the background. Returns immediately with a task id; output streams to a log file. You are sent a message when it finishes — keep working or end your turn meanwhile. Use for anything over a couple of minutes (builds, batch jobs, retry loops, downloads). Inspect with bg_status / bg_logs, stop with bg_kill. Pass resumable:true ONLY for an idempotent, long-lived command (a poller/watcher/retry loop) that should be requeued and relaunched on your next run if the sandbox goes away before it finishes (a sandbox is drained and replaced with a fresh one, not restarted in place) — the requeue re-runs the command from scratch, so never mark a one-shot side-effecting job (a migration, an apply, a send) resumable.",
4664
+ promptSnippet: "bg_run — run a long command without blocking; you are notified on completion (resumable:true requeues the task onto a fresh sandbox if the current one is drained, idempotent commands only)",
3513
4665
  parameters: Type.Object({
3514
4666
  command: Type.String({ description: "Bash command to execute" }),
3515
- description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." }))
4667
+ description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." })),
4668
+ resumable: Type.Optional(Type.Boolean({ description: "When true, this task is requeued and relaunched on your next run if the sandbox it is running on goes away before it finishes (a sandbox is drained and replaced with a fresh one, rather than restarted in place, so an in-flight task would otherwise be lost). Use ONLY for idempotent, long-lived commands (pollers, watchers, retry loops) — the requeue re-runs the command from scratch on the new sandbox, so never set this on a one-shot side-effecting command." }))
3516
4669
  }),
3517
4670
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3518
4671
  await ensureConversationId();
3519
- const { command, description = null } = params;
4672
+ const { command, description = null, resumable = false } = params;
3520
4673
  const meta = await launchTask({
3521
4674
  command,
3522
4675
  description,
3523
- cwd: ctx.cwd
4676
+ cwd: ctx.cwd,
4677
+ resumable
3524
4678
  });
4679
+ const resumeNote = resumable && meta.messageId ? "\nResumable: if this sandbox is drained before the task finishes, it will be requeued and relaunched from the start on your next run." : resumable ? "\nNote: resumable was requested but this session has no durable conversation, so it will NOT be requeued if the sandbox is drained." : "";
3525
4680
  return {
3526
4681
  content: [{
3527
4682
  type: "text",
3528
- text: `Started background task ${taskLabel(meta)}.\nlog: ${logPath(meta.id)}\nYou will get a message when it finishes. Check on it with bg_status {"taskId":"${meta.id}"}.`
4683
+ text: `Started background task ${taskLabel(meta)}.\nlog: ${logPath(meta.id)}\nYou will get a message when it finishes. Check on it with bg_status {"taskId":"${meta.id}"}.${resumeNote}`
3529
4684
  }],
3530
4685
  details: {}
3531
4686
  };
@@ -3645,6 +4800,7 @@ const all = [
3645
4800
  localToolsExtension,
3646
4801
  toolCallEnvExtension,
3647
4802
  bashDefaultTimeoutExtension,
4803
+ diskGuardExtension,
3648
4804
  toolCallSummaryExtension
3649
4805
  ];
3650
4806
  /**
@@ -3663,7 +4819,8 @@ function platformExtensions({ sessionId, channelContext }) {
3663
4819
  selfTraceExtension,
3664
4820
  createBackgroundTasksExtension({ channelContext }),
3665
4821
  createSubagentExtension({ channelContext }),
3666
- createContextManagementExtension()
4822
+ createContextManagementExtension(),
4823
+ resourcePressureWarningExtension
3667
4824
  ];
3668
4825
  }
3669
4826
  //#endregion