@skydiveai/pi-extensions 0.1.0-beta.1003

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,3942 @@
1
+ import { createRequire } from "node:module";
2
+ import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
3
+ import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
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";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { z } from "zod";
8
+ import { pathToFileURL } from "node:url";
9
+ import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
10
+ import { Type } from "typebox";
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
14
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
15
+ import { Check, Errors } from "typebox/value";
16
+ import { ROOT_CONTEXT, SpanStatusCode, propagation, trace } from "@opentelemetry/api";
17
+ import { W3CTraceContextPropagator } from "@opentelemetry/core";
18
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
19
+ import { Resource } from "@opentelemetry/resources";
20
+ import { BatchSpanProcessor, NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
21
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
22
+ import { hc } from "hono/client";
23
+ import { parse } from "yaml";
24
+ import { execFile } from "node:child_process";
25
+ import { availableParallelism } from "node:os";
26
+ import { promisify } from "node:util";
27
+ import { quote } from "shell-quote";
28
+ import { createWriteStream } from "node:fs";
29
+ import { finished } from "node:stream/promises";
30
+ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
31
+ //#region src/platform-env-middleware.ts
32
+ const ENVD_TOKEN_HEADER = "x-e2b-envd-token";
33
+ const ENVD_URL = "http://localhost:49983/envs";
34
+ const DAEMON_ENV_URL = "http://localhost:38994/v1/env";
35
+ const DAEMON_SET_ENVD_URL = "http://localhost:38994/v1/set-envd";
36
+ /**
37
+ * Identity env the daemon's `/v1/rebind` can swap when a warm pooled sandbox is
38
+ * claimed for a new agent. e2b envd (see below) is a create-time snapshot and
39
+ * can NEVER reflect a rebind, so these keys must come from the daemon store,
40
+ * which rebind mutates. Kept in sync with the daemon's REBINDABLE_KEYS.
41
+ */
42
+ const REBINDABLE_IDENTITY_KEYS = [
43
+ "ANYONE_AGENT_ID",
44
+ "ANYONE_SANDBOX_TOKEN",
45
+ "SKYDIVE_AGENT_ID",
46
+ "SKYDIVE_SANDBOX_TOKEN"
47
+ ];
48
+ let loaded = false;
49
+ function isPlatformConfigLoaded() {
50
+ return loaded;
51
+ }
52
+ async function loadPlatformConfig(request) {
53
+ if (loaded) return;
54
+ const token = request.headers.get(ENVD_TOKEN_HEADER) ?? void 0;
55
+ if (token) {
56
+ const envs = await fetchEnvdDirect(token);
57
+ if (envs && Object.keys(envs).length > 0) {
58
+ for (const [k, v] of Object.entries(envs)) process.env[k] = v;
59
+ const reboundKeys = await overlayReboundIdentity(token);
60
+ loaded = true;
61
+ logger.info({
62
+ event: "platform_config_loaded",
63
+ source: "envd",
64
+ keys: Object.keys(envs).length,
65
+ reboundKeys
66
+ }, "platform config loaded from envd");
67
+ setTimeout(() => pushToDaemon(token), 0);
68
+ return;
69
+ }
70
+ }
71
+ const envs = await fetchDaemonEnv(token);
72
+ if (envs) {
73
+ for (const [k, v] of Object.entries(envs)) process.env[k] = v;
74
+ loaded = true;
75
+ logger.info({
76
+ event: "platform_config_loaded",
77
+ source: "daemon",
78
+ keys: Object.keys(envs).length
79
+ }, "platform config loaded from daemon");
80
+ return;
81
+ }
82
+ logger.warn({ event: "platform_config_missing" }, "no platform config available");
83
+ }
84
+ /**
85
+ * Overlay the rebindable identity keys from the daemon env store onto
86
+ * `process.env`, so a claimed warm-pool sandbox resolves the claiming agent's
87
+ * identity rather than the create-time pool identity that e2b envd still holds.
88
+ * The daemon store is authoritative for these keys because `/v1/rebind` mutates
89
+ * it (and it persists across the harness restart the claim path performs).
90
+ * Returns the number of keys actually changed (0 on a fresh, never-rebound box).
91
+ */
92
+ async function overlayReboundIdentity(token) {
93
+ const daemonEnv = await fetchDaemonEnv(token);
94
+ if (!daemonEnv) return 0;
95
+ let changed = 0;
96
+ for (const key of REBINDABLE_IDENTITY_KEYS) {
97
+ const value = daemonEnv[key];
98
+ if (typeof value === "string" && value && process.env[key] !== value) {
99
+ process.env[key] = value;
100
+ changed += 1;
101
+ }
102
+ }
103
+ return changed;
104
+ }
105
+ async function fetchEnvdDirect(token) {
106
+ try {
107
+ const res = await fetch(ENVD_URL, { headers: { "X-Access-Token": token } });
108
+ if (!res.ok) return null;
109
+ const raw = await res.json();
110
+ if (!raw || typeof raw !== "object") return null;
111
+ const data = {};
112
+ for (const [k, v] of Object.entries(raw)) if (typeof v === "string") data[k] = v;
113
+ return data;
114
+ } catch (error) {
115
+ logger.error({ err: error }, "Failed to fetch envd direct");
116
+ return null;
117
+ }
118
+ }
119
+ function pushToDaemon(token) {
120
+ fetch(DAEMON_SET_ENVD_URL, {
121
+ method: "POST",
122
+ headers: {
123
+ "x-envd-token": token,
124
+ "Content-Type": "application/json"
125
+ },
126
+ body: JSON.stringify({ sandboxId: process.env["E2B_SANDBOX_ID"] ?? "" })
127
+ }).catch(() => {});
128
+ }
129
+ async function fetchDaemonEnv(token) {
130
+ try {
131
+ const headers = {};
132
+ if (token) headers["x-envd-token"] = token;
133
+ const res = await fetch(DAEMON_ENV_URL, { headers });
134
+ if (!res.ok) return null;
135
+ return await res.json();
136
+ } catch (error) {
137
+ logger.error({ err: error }, "Failed to fetch daemon env");
138
+ return null;
139
+ }
140
+ }
141
+ //#endregion
142
+ //#region src/versions.ts
143
+ /**
144
+ * Installed versions of the Skydive platform packages — the first-party
145
+ * packages the harness process loads (the pi server and these extensions),
146
+ * which we publish and bump. When they change, the agent is running different
147
+ * platform code. Surfaced via GET /health so a stale runtime is observable
148
+ * from outside a sandbox without shelling in (and so the self-upgrade flow can
149
+ * confirm an install took effect).
150
+ */
151
+ const require = createRequire(import.meta.url);
152
+ const PLATFORM_PACKAGES = ["@skydiveai/pi-server", "@skydiveai/pi-extensions"];
153
+ /**
154
+ * Resolve a package's own package.json by resolving its entry point and walking
155
+ * up to the nearest package.json whose `name` matches. We can't resolve
156
+ * `${pkg}/package.json` directly — these packages' `exports` don't expose it.
157
+ */
158
+ async function readPackageVersion(pkg) {
159
+ let dir;
160
+ try {
161
+ dir = dirname(require.resolve(pkg));
162
+ } catch {
163
+ return null;
164
+ }
165
+ let current = dir;
166
+ while (true) {
167
+ try {
168
+ const manifest = JSON.parse(await readFile(resolve(current, "package.json"), "utf8"));
169
+ if (manifest.name === pkg && typeof manifest.version === "string") return manifest.version;
170
+ } catch {}
171
+ const parent = dirname(current);
172
+ if (parent === current) return null;
173
+ current = parent;
174
+ }
175
+ }
176
+ /**
177
+ * Map of platform package name -> installed version. Packages that can't be
178
+ * resolved are omitted (running from source, partial install) rather than
179
+ * reported as a bogus version.
180
+ */
181
+ async function readPlatformVersions() {
182
+ const versions = {};
183
+ await Promise.all(PLATFORM_PACKAGES.map(async (pkg) => {
184
+ const version = await readPackageVersion(pkg);
185
+ if (version) versions[pkg] = version;
186
+ }));
187
+ return versions;
188
+ }
189
+ //#endregion
190
+ //#region src/platform-middleware.ts
191
+ /**
192
+ * Express-mountable platform middleware: Skydive env injection and the
193
+ * health endpoint. Built on @skydiveai/pi-server's node:http bridge
194
+ * helpers so the express app itself stays in the agent's workspace.
195
+ */
196
+ let platformVersionsPromise = null;
197
+ function platformVersions() {
198
+ return platformVersionsPromise ??= readPlatformVersions();
199
+ }
200
+ /**
201
+ * Middleware that loads platform config (e2b envd / daemon env) before
202
+ * requests reach the protocol handlers. Builds a headers-only Request —
203
+ * the body stream must stay untouched for the downstream handlers.
204
+ * Mount above everything except /health; it can wait up to 10s for env
205
+ * vars during boot.
206
+ */
207
+ function createPlatformEnvMiddleware() {
208
+ return async (req, _res, next) => {
209
+ try {
210
+ await loadPlatformConfig(new Request(requestUrl(req), { headers: requestHeaders(req) }));
211
+ } catch {}
212
+ next();
213
+ };
214
+ }
215
+ /**
216
+ * GET /health handler: platform health data plus whatever the agent's
217
+ * `metadata` callback returns. Must respond immediately (readiness
218
+ * probes, prewarm stashing) — mount it above the platform env
219
+ * middleware.
220
+ */
221
+ function createHealthHandler({ metadata }) {
222
+ return async (_req, res) => {
223
+ let meta;
224
+ try {
225
+ meta = metadata?.() ?? void 0;
226
+ } catch (err) {
227
+ logger.error({
228
+ err,
229
+ event: "health_metadata_failed"
230
+ }, "health metadata callback threw");
231
+ }
232
+ const runtimeVersions = await platformVersions();
233
+ res.writeHead(200, { "content-type": "application/json" });
234
+ res.end(JSON.stringify({
235
+ ok: true,
236
+ uptime: process.uptime(),
237
+ platformConfigLoaded: isPlatformConfigLoaded(),
238
+ runtimeVersions,
239
+ ...meta ? { metadata: meta } : {}
240
+ }));
241
+ };
242
+ }
243
+ //#endregion
244
+ //#region src/extensions/context-management-config.ts
245
+ /**
246
+ * Configuration for the context-management capability (tool-output trimming
247
+ * and client-side microcompact). See the design notes in the extension for
248
+ * what each layer does; this module is purely the env → config surface.
249
+ *
250
+ * The harness is provider-agnostic and runs inside the sandbox, where its
251
+ * only config channel is the environment (loaded by the platform env
252
+ * middleware before any session starts — see platform-env-middleware.ts). So
253
+ * the LaunchDarkly flag `harness-context-management-enabled` is resolved by
254
+ * the platform when it provisions the sandbox and passed through as
255
+ * `SKYDIVE_CONTEXT_MANAGEMENT`; the individual knobs override the defaults
256
+ * below when present.
257
+ *
258
+ * Resolution is defensive: a malformed value never throws (this config is
259
+ * read on the hot path before every LLM call), it falls back to the default
260
+ * for that knob and logs once.
261
+ */
262
+ const log$14 = logger.child({ module: "context-management-config" });
263
+ const DEFAULT_CONTEXT_MANAGEMENT_CONFIG = {
264
+ enabled: false,
265
+ perResultMaxBytes: 16 * 1024,
266
+ keepRecentToolResults: 3,
267
+ coldCacheGapSeconds: 240,
268
+ warmClearTriggerTokens: 5e4,
269
+ clearAtLeastTokens: 1e4,
270
+ excludeTools: [],
271
+ maxModelCallsPerTurn: 80,
272
+ nativeAnthropicEdits: false
273
+ };
274
+ const boolFromEnv = (value, fallback) => {
275
+ if (value === void 0) return fallback;
276
+ const normalized = value.trim().toLowerCase();
277
+ if (normalized === "1" || normalized === "true") return true;
278
+ if (normalized === "0" || normalized === "false") return false;
279
+ return fallback;
280
+ };
281
+ const positiveInt = (fallback) => z.coerce.number().int().positive().catch(fallback);
282
+ const nonNegativeInt = (fallback) => z.coerce.number().int().nonnegative().catch(fallback);
283
+ const configSchema = z.object({
284
+ perResultMaxBytes: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.perResultMaxBytes),
285
+ keepRecentToolResults: nonNegativeInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.keepRecentToolResults),
286
+ coldCacheGapSeconds: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.coldCacheGapSeconds),
287
+ warmClearTriggerTokens: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.warmClearTriggerTokens),
288
+ clearAtLeastTokens: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.clearAtLeastTokens),
289
+ maxModelCallsPerTurn: positiveInt(DEFAULT_CONTEXT_MANAGEMENT_CONFIG.maxModelCallsPerTurn)
290
+ });
291
+ const parseExcludeTools = (value) => {
292
+ if (!value) return DEFAULT_CONTEXT_MANAGEMENT_CONFIG.excludeTools;
293
+ return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
294
+ };
295
+ /**
296
+ * Reads the context-management config from `env` (defaults to `process.env`).
297
+ * Never throws — invalid values fall back to defaults.
298
+ */
299
+ function resolveContextManagementConfig(env = process.env) {
300
+ if (!boolFromEnv(env.SKYDIVE_CONTEXT_MANAGEMENT, false)) return { ...DEFAULT_CONTEXT_MANAGEMENT_CONFIG };
301
+ const parsed = configSchema.safeParse({
302
+ perResultMaxBytes: env.SKYDIVE_CTX_PER_RESULT_MAX_BYTES,
303
+ keepRecentToolResults: env.SKYDIVE_CTX_KEEP_RECENT,
304
+ coldCacheGapSeconds: env.SKYDIVE_CTX_COLD_GAP_SECONDS,
305
+ warmClearTriggerTokens: env.SKYDIVE_CTX_WARM_TRIGGER_TOKENS,
306
+ clearAtLeastTokens: env.SKYDIVE_CTX_CLEAR_AT_LEAST_TOKENS,
307
+ maxModelCallsPerTurn: env.SKYDIVE_CTX_MAX_MODEL_CALLS
308
+ });
309
+ if (!parsed.success) {
310
+ log$14.warn({
311
+ event: "context_management_config_invalid",
312
+ err: parsed.error
313
+ }, "falling back to default context-management config");
314
+ return {
315
+ ...DEFAULT_CONTEXT_MANAGEMENT_CONFIG,
316
+ enabled: true
317
+ };
318
+ }
319
+ return {
320
+ enabled: true,
321
+ ...parsed.data,
322
+ excludeTools: parseExcludeTools(env.SKYDIVE_CTX_EXCLUDE_TOOLS),
323
+ nativeAnthropicEdits: boolFromEnv(env.SKYDIVE_CTX_NATIVE_ANTHROPIC_EDITS, DEFAULT_CONTEXT_MANAGEMENT_CONFIG.nativeAnthropicEdits)
324
+ };
325
+ }
326
+ //#endregion
327
+ //#region src/extensions/context-management-runtime.ts
328
+ /**
329
+ * Live, mutable view of the context-management config.
330
+ *
331
+ * The env-derived config (context-management-config.ts) is the boot-time
332
+ * default. On top of it, the platform can deliver a global on/off at runtime
333
+ * via the LaunchDarkly flag `harness-context-management-enabled` — resolved
334
+ * server-side and polled by the harness (see the poller in
335
+ * context-management.ts). This holder is where that override lands so a flip
336
+ * (especially a kill-switch) reaches long-lived sandboxes without a restart.
337
+ *
338
+ * Only the master `enabled` toggle is overridable at runtime; the per-knob
339
+ * tunables stay env-derived. `enabled` resolves to the flag override when the
340
+ * platform has reported one, else the env value.
341
+ */
342
+ let baseConfig = null;
343
+ let flagOverride = null;
344
+ function base() {
345
+ baseConfig ??= resolveContextManagementConfig();
346
+ return baseConfig;
347
+ }
348
+ /** The effective config, with the runtime flag override applied to `enabled`. */
349
+ function getContextManagementConfig() {
350
+ const resolved = base();
351
+ return {
352
+ ...resolved,
353
+ enabled: flagOverride ?? resolved.enabled
354
+ };
355
+ }
356
+ /**
357
+ * Apply the platform-reported flag value. `null` clears the override (fall back
358
+ * to the env default) — used when the phone-home result is indeterminate so a
359
+ * transient failure never silently changes behavior.
360
+ */
361
+ function setContextManagementFlagOverride(enabled) {
362
+ flagOverride = enabled;
363
+ }
364
+ /**
365
+ * Whether a phone-home channel exists to learn the flag at runtime. When false
366
+ * (e.g. a bare local CLI with no platform API), the env value is the only
367
+ * source and there's nothing to poll.
368
+ */
369
+ function hasFlagSource(env = process.env) {
370
+ return Boolean(env.SKYDIVE_API_URL ?? env.ANYONE_API_URL);
371
+ }
372
+ //#endregion
373
+ //#region src/iteration-cap.ts
374
+ function installIterationCap({ session, log }, configOverride = null) {
375
+ const readConfig = () => configOverride ?? getContextManagementConfig();
376
+ if (!readConfig().enabled && (configOverride !== null || !hasFlagSource())) return;
377
+ const agent = session.agent;
378
+ if (typeof agent.createLoopConfig !== "function") throw new Error("installIterationCap: session.agent.createLoopConfig is missing — pi-agent-core internals changed; update iteration-cap.ts.");
379
+ const original = agent.createLoopConfig.bind(agent);
380
+ agent.createLoopConfig = (options) => {
381
+ const loopConfig = original(options);
382
+ const previousStop = loopConfig.shouldStopAfterTurn;
383
+ let modelCalls = 0;
384
+ return {
385
+ ...loopConfig,
386
+ shouldStopAfterTurn: async (ctx) => {
387
+ if (previousStop && await previousStop(ctx)) return true;
388
+ const config = readConfig();
389
+ if (!config.enabled) return false;
390
+ modelCalls += 1;
391
+ if (modelCalls >= config.maxModelCallsPerTurn) {
392
+ log.warn({
393
+ event: "iteration_cap_reached",
394
+ modelCalls,
395
+ max: config.maxModelCallsPerTurn
396
+ }, "reached per-turn model-call cap; stopping turn gracefully");
397
+ return true;
398
+ }
399
+ return false;
400
+ }
401
+ };
402
+ };
403
+ }
404
+ //#endregion
405
+ //#region src/extensions/capability-soul-nudge.ts
406
+ /**
407
+ * One-line reminder appended to a tool-update continuation when the agent
408
+ * gains a *new* capability (a freshly-loaded tool file or a newly-connected
409
+ * MCP server — not a refresh or removal). It points the agent back at
410
+ * `soul.md`, which the soul extension frames as the durable home of identity
411
+ * *and* capabilities. Without this, an added capability lives only in the
412
+ * mechanical tool/skill inventory and never becomes part of the self-concept
413
+ * a future conversation starts from — the "agent forgets what it can do"
414
+ * gap.
415
+ *
416
+ * Phrased to defer ("once the current task is done") so it nudges reflection
417
+ * without derailing the in-flight turn into a soul edit.
418
+ */
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).";
420
+ //#endregion
421
+ //#region src/extensions/local-tools.ts
422
+ /**
423
+ * Local-tools adapter as a pi extension. Mirrors the mcp.ts hot-reload pattern
424
+ * one level over: walks `tools/` (relative to the harness cwd), imports each
425
+ * module, and registers the exported `ToolDefinition`s with pi.
426
+ *
427
+ * The agent edits files under `tools/` between turns; on every `tool_result`
428
+ * the extension stat()s the directory's children, compares mtimes against
429
+ * what we last reconciled against, and queues a `pendingLocalToolsUpdate`
430
+ * if anything was added/removed/changed. The chat handler drains that queue
431
+ * after the current `session.prompt(...)` returns, calls `session.reload()`,
432
+ * and injects a synthetic continuation message — same dance as MCP.
433
+ *
434
+ * **ESM cache-busting.** `import(url)` in Node's ESM loader keys cached
435
+ * modules by URL. Re-importing the same path after editing the file gets
436
+ * the *original* module back. To force a fresh load on mtime change we
437
+ * append `?v=<mtimeMs>` to the URL — different URL, fresh module
438
+ * evaluation. The old version stays in memory but is unreachable.
439
+ *
440
+ * Each `.ts`/`.mjs`/`.js` file's default export should be a `ToolDefinition`
441
+ * or `ToolDefinition[]`. Files starting with `_` or `.` are skipped, so
442
+ * `tools/_example.ts` documents the shape without registering.
443
+ */
444
+ const log$13 = logger.child({ module: "local-tools-extension" });
445
+ const TOOLS_DIRNAME = "tools";
446
+ const fileState = /* @__PURE__ */ new Map();
447
+ let pendingLocalToolsUpdate = null;
448
+ function consumePendingLocalToolsUpdate() {
449
+ const update = pendingLocalToolsUpdate;
450
+ pendingLocalToolsUpdate = null;
451
+ return update;
452
+ }
453
+ /**
454
+ * Non-consuming peek used by the agent loop's `shouldStopAfterTurn` hook to
455
+ * decide whether to end the current `prompt()` after the in-flight turn so a
456
+ * fresh tool snapshot can be taken. The drain still happens in postPrompt via
457
+ * `consumePendingLocalToolsUpdate`.
458
+ */
459
+ function hasPendingLocalToolsUpdate() {
460
+ return pendingLocalToolsUpdate !== null;
461
+ }
462
+ function formatLocalToolsUpdateMessage(summary) {
463
+ const lines = ["[system] Your local tools/ inventory changed during the previous turn. Your tool list is now updated; act on the new set rather than what was visible before."];
464
+ if (summary.added.length > 0) lines.push(`Newly loaded tool files: ${summary.added.join(", ")}`);
465
+ if (summary.refreshed.length > 0) lines.push(`Refreshed tool files: ${summary.refreshed.join(", ")}`);
466
+ if (summary.removed.length > 0) lines.push(`Removed tool files (and their tools): ${summary.removed.join(", ")}`);
467
+ if (summary.errors.length > 0) {
468
+ lines.push("Errors:");
469
+ for (const e of summary.errors) lines.push(` - ${e.file}: ${e.message}`);
470
+ }
471
+ if (summary.added.length > 0) lines.push(CAPABILITY_SOUL_NUDGE);
472
+ lines.push("Continue from where you left off, using the current tool list. Do not re-do work that already succeeded last turn.");
473
+ return lines.join("\n");
474
+ }
475
+ function isToolDefinition(x) {
476
+ return !!x && typeof x === "object" && typeof x.name === "string" && typeof x.execute === "function";
477
+ }
478
+ /**
479
+ * Pi's system-prompt builder filters its visible-tool list to entries with a
480
+ * non-empty `promptSnippet` (system-prompt.js:49) — a tool with only a
481
+ * `description` won't appear in the system prompt's "available tools"
482
+ * section, which biases the LLM against using it. If the local tool author
483
+ * didn't set a snippet, default to the tool's description so the tool stays
484
+ * visible.
485
+ */
486
+ function withDefaultPromptSnippet(tool) {
487
+ if (typeof tool.promptSnippet === "string" && tool.promptSnippet.trim()) return tool;
488
+ const fallback = tool.description?.trim();
489
+ if (!fallback) return tool;
490
+ return {
491
+ ...tool,
492
+ promptSnippet: fallback
493
+ };
494
+ }
495
+ async function listToolFiles(dir) {
496
+ let entries;
497
+ try {
498
+ entries = await readdir(dir);
499
+ } catch (err) {
500
+ if (err?.code === "ENOENT") return [];
501
+ throw err;
502
+ }
503
+ const out = [];
504
+ for (const file of entries) {
505
+ if (!file.endsWith(".ts") && !file.endsWith(".mjs") && !file.endsWith(".js")) continue;
506
+ if (file.startsWith("_") || file.startsWith(".")) continue;
507
+ const full = join(dir, file);
508
+ try {
509
+ const s = await stat(full);
510
+ if (!s.isFile()) continue;
511
+ out.push({
512
+ file,
513
+ mtimeMs: s.mtimeMs
514
+ });
515
+ } catch {}
516
+ }
517
+ return out;
518
+ }
519
+ async function importToolFile({ dir, file, mtimeMs }) {
520
+ const mod = await import(`${pathToFileURL(join(dir, file)).href}?v=${mtimeMs}`);
521
+ const exported = mod.default ?? mod.tool ?? mod.tools;
522
+ const tools = [];
523
+ if (Array.isArray(exported)) {
524
+ for (const t of exported) if (isToolDefinition(t)) tools.push(t);
525
+ } else if (isToolDefinition(exported)) tools.push(exported);
526
+ if (tools.length === 0) throw new Error("no valid ToolDefinition export");
527
+ return tools;
528
+ }
529
+ async function reconcileLocalTools({ pi, dir }) {
530
+ const summary = {
531
+ added: [],
532
+ removed: [],
533
+ refreshed: [],
534
+ errors: [],
535
+ totalTools: 0
536
+ };
537
+ const current = await listToolFiles(dir);
538
+ const currentByName = new Map(current.map((e) => [e.file, e.mtimeMs]));
539
+ for (const file of [...fileState.keys()]) if (!currentByName.has(file)) {
540
+ fileState.delete(file);
541
+ summary.removed.push(file);
542
+ }
543
+ for (const { file, mtimeMs } of current) {
544
+ const existing = fileState.get(file);
545
+ let tools;
546
+ let action;
547
+ if (existing && existing.mtimeMs === mtimeMs) {
548
+ tools = existing.tools;
549
+ action = "reused";
550
+ } else {
551
+ try {
552
+ tools = await importToolFile({
553
+ dir,
554
+ file,
555
+ mtimeMs
556
+ });
557
+ } catch (err) {
558
+ summary.errors.push({
559
+ file,
560
+ message: err instanceof Error ? err.message : String(err)
561
+ });
562
+ continue;
563
+ }
564
+ fileState.set(file, {
565
+ mtimeMs,
566
+ tools
567
+ });
568
+ action = existing ? "refreshed" : "added";
569
+ }
570
+ for (const tool of tools) {
571
+ pi.registerTool(withDefaultPromptSnippet(tool));
572
+ summary.totalTools++;
573
+ }
574
+ if (action === "added") summary.added.push(file);
575
+ else if (action === "refreshed") summary.refreshed.push(file);
576
+ }
577
+ return summary;
578
+ }
579
+ function summaryHasChanges$1(s) {
580
+ return s.added.length > 0 || s.removed.length > 0 || s.refreshed.length > 0 || s.errors.length > 0;
581
+ }
582
+ async function reconcileAndQueue({ pi, dir, reason }) {
583
+ const summary = await reconcileLocalTools({
584
+ pi,
585
+ dir
586
+ });
587
+ if (reason !== "session_start" && summaryHasChanges$1(summary)) pendingLocalToolsUpdate = summary;
588
+ log$13.info({
589
+ event: "local_tools_reconcile",
590
+ reason,
591
+ total_tools: summary.totalTools,
592
+ added: summary.added,
593
+ removed: summary.removed,
594
+ refreshed: summary.refreshed,
595
+ errors: summary.errors,
596
+ queued_continuation: reason !== "session_start" && summaryHasChanges$1(summary)
597
+ }, "local tools reconcile complete");
598
+ return summary;
599
+ }
600
+ const localToolsExtension = (pi) => {
601
+ pi.on("session_start", async (_event, ctx) => {
602
+ const dir = join(ctx.cwd, TOOLS_DIRNAME);
603
+ try {
604
+ await reconcileAndQueue({
605
+ pi,
606
+ dir,
607
+ reason: "session_start"
608
+ });
609
+ } catch (err) {
610
+ log$13.error({
611
+ err,
612
+ event: "local_tools_reconcile_failed"
613
+ }, "local tools reconcile failed");
614
+ }
615
+ });
616
+ pi.on("tool_result", async (_event, ctx) => {
617
+ const dir = join(ctx.cwd, TOOLS_DIRNAME);
618
+ let current;
619
+ try {
620
+ current = await listToolFiles(dir);
621
+ } catch (err) {
622
+ log$13.warn({
623
+ err,
624
+ event: "local_tools_listing_failed"
625
+ }, "tools/ listing failed");
626
+ return;
627
+ }
628
+ let changed = current.length !== fileState.size;
629
+ if (!changed) for (const { file, mtimeMs } of current) {
630
+ const existing = fileState.get(file);
631
+ if (!existing || existing.mtimeMs !== mtimeMs) {
632
+ changed = true;
633
+ break;
634
+ }
635
+ }
636
+ if (!changed) return;
637
+ try {
638
+ await reconcileAndQueue({
639
+ pi,
640
+ dir,
641
+ reason: "auto_reload"
642
+ });
643
+ } catch (err) {
644
+ log$13.error({
645
+ err,
646
+ event: "local_tools_auto_reload_failed"
647
+ }, "auto-reload after tools/ change failed");
648
+ }
649
+ });
650
+ };
651
+ //#endregion
652
+ //#region src/extensions/mcp/stderr-buffer.ts
653
+ function createStderrBuffer({ maxBytes }) {
654
+ const chunks = [];
655
+ let bytes = 0;
656
+ return {
657
+ attach(stream) {
658
+ stream.on("data", (chunk) => {
659
+ chunks.push(chunk);
660
+ bytes += chunk.length;
661
+ while (bytes > maxBytes && chunks.length > 1) {
662
+ const dropped = chunks.shift();
663
+ bytes -= dropped.length;
664
+ }
665
+ });
666
+ },
667
+ read() {
668
+ if (chunks.length === 0) return "";
669
+ const full = Buffer.concat(chunks).toString("utf8");
670
+ return full.length > maxBytes ? full.slice(full.length - maxBytes) : full;
671
+ }
672
+ };
673
+ }
674
+ //#endregion
675
+ //#region src/extensions/mcp/connect-client.ts
676
+ /**
677
+ * Spawns an MCP server's transport and races the initialize handshake
678
+ * against a bounded timeout. The motivating case is OAuth-pending
679
+ * bridges like `mcp-remote`: they spawn fine, print their auth URL to
680
+ * stderr, then block on JSON-RPC `initialize` until the user completes
681
+ * OAuth. Without a bounded wait, `client.connect(transport)` hangs
682
+ * forever and the reconcile loop stalls.
683
+ *
684
+ * On timeout we return `status: 'timeout'` with the captured stderr.
685
+ * The transport stays alive — caller holds the client so the bridge's
686
+ * callback server keeps listening and a later reconcile re-probes.
687
+ */
688
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5e3;
689
+ const STDERR_BUFFER_BYTES = 4096;
690
+ /**
691
+ * undici's fetch throws `TypeError: fetch failed` with the actual
692
+ * network error hung off `.cause` (e.g. `getaddrinfo ENOTFOUND ...`,
693
+ * `ECONNREFUSED`, TLS errors). Surfacing only `err.message` makes
694
+ * every network failure read as a bare "fetch failed", which is
695
+ * indistinguishable from any other transport problem. Walk the cause
696
+ * chain so the agent sees the real underlying error.
697
+ */
698
+ function formatError(err) {
699
+ if (!(err instanceof Error)) return String(err);
700
+ const parts = [err.message];
701
+ let cursor = err.cause;
702
+ while (cursor instanceof Error) {
703
+ const code = cursor.code;
704
+ parts.push(typeof code === "string" ? `${cursor.message} (${code})` : cursor.message);
705
+ cursor = cursor.cause;
706
+ }
707
+ return parts.join(": ");
708
+ }
709
+ async function connectHttp(_id, config, client) {
710
+ const transport = new StreamableHTTPClientTransport(new URL(config.url), { ...config.headers !== null ? { requestInit: { headers: config.headers } } : {} });
711
+ try {
712
+ await client.connect(transport);
713
+ return {
714
+ status: "connected",
715
+ client,
716
+ stderr: null
717
+ };
718
+ } catch (err) {
719
+ if (err instanceof UnauthorizedError || err instanceof StreamableHTTPError && err.code === 401) return {
720
+ status: "pending_auth",
721
+ client,
722
+ stderr: "",
723
+ stderrBuffer: null,
724
+ cliHint: `platform auth mcp ${config.url}`
725
+ };
726
+ return {
727
+ status: "failed",
728
+ error: formatError(err),
729
+ stderr: ""
730
+ };
731
+ }
732
+ }
733
+ async function connectClient(id, config, opts = {}) {
734
+ const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
735
+ const client = new Client({
736
+ name: "skydive-harness",
737
+ version: "0.1.0"
738
+ }, { capabilities: {} });
739
+ if (config.transport === "http") return connectHttp(id, config, client);
740
+ const params = {
741
+ command: config.command,
742
+ args: config.args,
743
+ stderr: "pipe"
744
+ };
745
+ if (config.env !== null) params.env = config.env;
746
+ if (config.cwd !== null) params.cwd = config.cwd;
747
+ const transport = new StdioClientTransport(params);
748
+ const stderrBuffer = createStderrBuffer({ maxBytes: STDERR_BUFFER_BYTES });
749
+ if (transport.stderr) stderrBuffer.attach(transport.stderr);
750
+ let exited = null;
751
+ const exitPromise = new Promise((resolve) => {
752
+ transport.onclose = () => {
753
+ exited = { code: null };
754
+ resolve();
755
+ };
756
+ });
757
+ const connectPromise = client.connect(transport);
758
+ const TIMEOUT_SENTINEL = Symbol("timeout");
759
+ const result = await Promise.race([
760
+ connectPromise.then(() => "connected").catch((err) => err),
761
+ exitPromise.then(() => "exited"),
762
+ new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs))
763
+ ]);
764
+ if (result === "connected") return {
765
+ status: "connected",
766
+ client,
767
+ stderr: stderrBuffer
768
+ };
769
+ if (result === "exited" || exited !== null) return {
770
+ status: "failed",
771
+ error: `server "${id}" exited before completing initialize`,
772
+ stderr: stderrBuffer.read()
773
+ };
774
+ if (result === TIMEOUT_SENTINEL) return {
775
+ status: "timeout",
776
+ client,
777
+ stderr: stderrBuffer.read(),
778
+ stderrBuffer
779
+ };
780
+ try {
781
+ await client.close();
782
+ } catch {}
783
+ return {
784
+ status: "failed",
785
+ error: formatError(result),
786
+ stderr: stderrBuffer.read()
787
+ };
788
+ }
789
+ //#endregion
790
+ //#region src/extensions/mcp/mcp-config.ts
791
+ /**
792
+ * mcp.config.json schema + loader. Split out from the extension so it has
793
+ * a small, focused unit-test surface (typebox validation, error formatting).
794
+ */
795
+ const MCP_CONFIG_FILENAME = "mcp.config.json";
796
+ const StdioServerSchema = Type.Object({
797
+ transport: Type.Literal("stdio"),
798
+ command: Type.String(),
799
+ args: Type.Array(Type.String()),
800
+ env: Type.Optional(Type.Union([Type.Record(Type.String(), Type.String()), Type.Null()])),
801
+ cwd: Type.Optional(Type.Union([Type.String(), Type.Null()]))
802
+ });
803
+ const HttpServerSchema = Type.Object({
804
+ transport: Type.Literal("http"),
805
+ url: Type.String(),
806
+ headers: Type.Optional(Type.Union([Type.Record(Type.String(), Type.String()), Type.Null()]))
807
+ });
808
+ const ServerSchema = Type.Union([StdioServerSchema, HttpServerSchema]);
809
+ const McpConfigSchema = Type.Object({ servers: Type.Record(Type.String(), ServerSchema) });
810
+ const EMPTY_CONFIG = { servers: {} };
811
+ async function loadMcpConfig(path) {
812
+ let text;
813
+ try {
814
+ text = await readFile(path, "utf8");
815
+ } catch (err) {
816
+ if (err?.code === "ENOENT") return EMPTY_CONFIG;
817
+ throw err;
818
+ }
819
+ let json;
820
+ try {
821
+ json = JSON.parse(text);
822
+ } catch (err) {
823
+ throw new Error(`Invalid JSON in MCP config at ${path}: ${err.message}`, { cause: err });
824
+ }
825
+ if (!Check(McpConfigSchema, json)) {
826
+ const details = Errors(McpConfigSchema, json).map((e) => ` ${e.instancePath || "/"}: ${e.message}`).join("\n");
827
+ throw new Error(`Invalid MCP config at ${path}:\n${details}`);
828
+ }
829
+ const servers = {};
830
+ for (const [id, server] of Object.entries(json.servers)) if (server.transport === "stdio") servers[id] = {
831
+ ...server,
832
+ env: server.env ?? null,
833
+ cwd: server.cwd ?? null
834
+ };
835
+ else servers[id] = {
836
+ ...server,
837
+ headers: server.headers ?? null
838
+ };
839
+ return { servers };
840
+ }
841
+ //#endregion
842
+ //#region src/extensions/mcp/index.ts
843
+ /**
844
+ * MCP adapter as a pi extension.
845
+ *
846
+ * Owns the lifecycle of MCP server clients and registers their tools with pi
847
+ * via `pi.registerTool`. The harness process is long-lived and runs one
848
+ * McpExtension instance — state (open clients, registered tool names,
849
+ * mtime baseline, pending update) is per-instance so tests can construct
850
+ * fresh ones instead of poking module-scoped vars.
851
+ *
852
+ * **Mid-turn tool-list updates do not work in pi-agent-core.** The agent
853
+ * loop snapshots `state.tools` at the top of each `session.prompt(...)`
854
+ * and reuses that snapshot for every LLM iteration in the turn. New
855
+ * registrations only land in the *next* prompt's snapshot. The harness
856
+ * orchestrates around this via `runToolUpdateLoop` (postPrompt):
857
+ *
858
+ * 1. `session.reload()` rebuilds pi's tool registry; our session_start
859
+ * hook re-reconciles against an empty registry, so removed servers
860
+ * drop out (pi has no `unregisterTool`).
861
+ * 2. `consumePendingMcpUpdate()` + `formatMcpUpdateMessage()` produce
862
+ * a synthetic system note injected via `sendCustomMessage(...,
863
+ * { triggerTurn: true })` — fresh `state.tools` snapshot for the
864
+ * next prompt.
865
+ *
866
+ * Clients are keyed by JSON-stringified config and reused across
867
+ * reloads — only changed configs reconnect.
868
+ */
869
+ const log$12 = logger.child({ module: "mcp-extension" });
870
+ async function closeConnected(connected) {
871
+ try {
872
+ await connected.client.close();
873
+ } catch (err) {
874
+ logger.warn({
875
+ err,
876
+ event: "mcp_client_close_failed"
877
+ }, "failed to close MCP client; transport may leak");
878
+ }
879
+ }
880
+ async function readConfigMtimeMs(configPath) {
881
+ try {
882
+ return (await stat(configPath)).mtimeMs;
883
+ } catch (err) {
884
+ if (err?.code === "ENOENT") return 0;
885
+ throw err;
886
+ }
887
+ }
888
+ function makeToolName(serverId, toolName) {
889
+ return `mcp_${serverId}_${toolName}`;
890
+ }
891
+ function toMcpArguments(params) {
892
+ const out = {};
893
+ if (!params || typeof params !== "object" || Array.isArray(params)) return out;
894
+ for (const [key, value] of Object.entries(params)) out[key] = value;
895
+ return out;
896
+ }
897
+ function isCallToolResult(result) {
898
+ return "content" in result;
899
+ }
900
+ function mcpContentPartToText(part) {
901
+ switch (part.type) {
902
+ case "text": return part.text;
903
+ case "audio": return `[audio: ${part.mimeType}]`;
904
+ case "resource": {
905
+ const { resource } = part;
906
+ if ("text" in resource) return resource.text;
907
+ return `[resource: ${resource.uri}]`;
908
+ }
909
+ case "resource_link": return `[resource link: ${part.name} (${part.uri})]`;
910
+ default: return `[unsupported MCP content type: ${JSON.stringify(part)}]`;
911
+ }
912
+ }
913
+ function mcpResultToPiContent(result) {
914
+ const content = [];
915
+ const textParts = [];
916
+ if (result.isError) textParts.push("MCP tool reported an error.");
917
+ for (const part of result.content) {
918
+ if (part.type === "image") {
919
+ content.push({
920
+ type: "image",
921
+ data: part.data,
922
+ mimeType: part.mimeType
923
+ });
924
+ continue;
925
+ }
926
+ const text = mcpContentPartToText(part);
927
+ if (text) textParts.push(text);
928
+ }
929
+ if (result.structuredContent) textParts.push(`Structured content:\n${JSON.stringify(result.structuredContent, null, 2)}`);
930
+ if (textParts.length > 0) content.unshift({
931
+ type: "text",
932
+ text: textParts.join("\n")
933
+ });
934
+ return content.length > 0 ? content : [{
935
+ type: "text",
936
+ text: "MCP tool returned no content."
937
+ }];
938
+ }
939
+ var McpExtension = class {
940
+ mcpClients = /* @__PURE__ */ new Map();
941
+ registeredMcpToolNames = /* @__PURE__ */ new Set();
942
+ lastConfigMtimeMs = 0;
943
+ pendingMcpUpdate = null;
944
+ /** Non-consuming peek used by `shouldStopAfterTurn`. */
945
+ hasPendingUpdate() {
946
+ return this.pendingMcpUpdate !== null;
947
+ }
948
+ /** Read + clear the queued mid-turn update; null if nothing pending. */
949
+ consumePendingUpdate() {
950
+ const update = this.pendingMcpUpdate;
951
+ this.pendingMcpUpdate = null;
952
+ return update;
953
+ }
954
+ registerMcpTool({ pi, serverId, client, tool }) {
955
+ const name = makeToolName(serverId, tool.name);
956
+ const parameters = Type.Unsafe(tool.inputSchema);
957
+ const description = tool.description?.trim() ?? "";
958
+ const promptSnippet = description.length > 0 ? description : `MCP tool from server "${serverId}".`;
959
+ pi.registerTool({
960
+ name,
961
+ label: `MCP: ${serverId}/${tool.name}`,
962
+ description,
963
+ promptSnippet,
964
+ parameters,
965
+ async execute(_toolCallId, params) {
966
+ try {
967
+ const result = await client.callTool({
968
+ name: tool.name,
969
+ arguments: toMcpArguments(params)
970
+ }, CallToolResultSchema);
971
+ if (!isCallToolResult(result)) throw new Error("MCP tool returned an unsupported compatibility result");
972
+ return {
973
+ content: mcpResultToPiContent(result),
974
+ details: {
975
+ result,
976
+ error: null
977
+ }
978
+ };
979
+ } catch (err) {
980
+ const message = err instanceof Error ? err.message : String(err);
981
+ return {
982
+ content: [{
983
+ type: "text",
984
+ text: `MCP tool ${serverId}/${tool.name} failed: ${message}`
985
+ }],
986
+ details: {
987
+ result: null,
988
+ error: message
989
+ }
990
+ };
991
+ }
992
+ }
993
+ });
994
+ this.registeredMcpToolNames.add(name);
995
+ }
996
+ async reconcile({ pi, configPath, connectTimeoutMs }) {
997
+ let config;
998
+ try {
999
+ config = await loadMcpConfig(configPath);
1000
+ } catch (err) {
1001
+ throw new Error(`Failed to load MCP config: ${err instanceof Error ? err.message : String(err)}`);
1002
+ }
1003
+ const summary = {
1004
+ added: [],
1005
+ removed: [],
1006
+ refreshed: [],
1007
+ errors: [],
1008
+ totalTools: 0,
1009
+ servers: {}
1010
+ };
1011
+ const desiredIds = new Set(Object.keys(config.servers));
1012
+ this.registeredMcpToolNames.clear();
1013
+ for (const [id, existing] of [...this.mcpClients.entries()]) if (!desiredIds.has(id)) {
1014
+ await closeConnected(existing);
1015
+ this.mcpClients.delete(id);
1016
+ summary.removed.push(id);
1017
+ }
1018
+ const outcomes = await Promise.all(Object.entries(config.servers).map(([id, serverConfig]) => this.reconcileServer({
1019
+ id,
1020
+ serverConfig,
1021
+ existing: this.mcpClients.get(id),
1022
+ connectTimeoutMs
1023
+ })));
1024
+ for (const outcome of outcomes) {
1025
+ const { id } = outcome;
1026
+ if (outcome.store) this.mcpClients.set(id, outcome.store);
1027
+ else this.mcpClients.delete(id);
1028
+ summary.servers[id] = outcome.serverStatus;
1029
+ if (outcome.error) summary.errors.push(outcome.error);
1030
+ if (outcome.change === "added") summary.added.push(id);
1031
+ else if (outcome.change === "refreshed") summary.refreshed.push(id);
1032
+ if (outcome.tools) for (const tool of outcome.tools.list) {
1033
+ this.registerMcpTool({
1034
+ pi,
1035
+ serverId: id,
1036
+ client: outcome.tools.client,
1037
+ tool
1038
+ });
1039
+ summary.totalTools++;
1040
+ }
1041
+ }
1042
+ return summary;
1043
+ }
1044
+ /**
1045
+ * Probe one server end-to-end (connect → retry pending → listTools) and
1046
+ * return what should be applied to shared state. Touches only its own
1047
+ * client (closing a stale one before reconnecting), never the shared
1048
+ * map/summary, so callers can run these concurrently.
1049
+ */
1050
+ async reconcileServer({ id, serverConfig, existing, connectTimeoutMs }) {
1051
+ const configKey = JSON.stringify(serverConfig);
1052
+ let action;
1053
+ let connected;
1054
+ if (existing && existing.configKey === configKey) {
1055
+ connected = existing;
1056
+ action = "reused";
1057
+ } else {
1058
+ if (existing) await closeConnected(existing);
1059
+ const result = await connectClient(id, serverConfig, { connectTimeoutMs });
1060
+ const change = existing ? "refreshed" : "added";
1061
+ if (result.status === "failed") return {
1062
+ id,
1063
+ store: null,
1064
+ serverStatus: {
1065
+ status: "failed",
1066
+ error: result.error,
1067
+ stderr: result.stderr
1068
+ },
1069
+ change,
1070
+ error: {
1071
+ serverId: id,
1072
+ message: result.error
1073
+ },
1074
+ tools: null
1075
+ };
1076
+ if (result.status === "pending_auth") return {
1077
+ id,
1078
+ store: {
1079
+ client: result.client,
1080
+ configKey,
1081
+ status: "pending_auth",
1082
+ stderrBuffer: result.stderrBuffer,
1083
+ cliHint: result.cliHint
1084
+ },
1085
+ serverStatus: {
1086
+ status: "pending_auth",
1087
+ stderr: result.stderr,
1088
+ cliHint: result.cliHint
1089
+ },
1090
+ change,
1091
+ error: null,
1092
+ tools: null
1093
+ };
1094
+ if (result.status === "timeout") return {
1095
+ id,
1096
+ store: {
1097
+ client: result.client,
1098
+ configKey,
1099
+ status: "timeout",
1100
+ stderrBuffer: result.stderrBuffer,
1101
+ cliHint: null
1102
+ },
1103
+ serverStatus: {
1104
+ status: "timeout",
1105
+ stderr: result.stderr
1106
+ },
1107
+ change,
1108
+ error: null,
1109
+ tools: null
1110
+ };
1111
+ connected = {
1112
+ client: result.client,
1113
+ configKey,
1114
+ status: "connected",
1115
+ stderrBuffer: result.stderr,
1116
+ cliHint: null
1117
+ };
1118
+ action = existing ? "refreshed" : "added";
1119
+ }
1120
+ if (connected.status === "pending_auth") {
1121
+ await closeConnected(connected);
1122
+ const retry = await connectClient(id, serverConfig, { connectTimeoutMs });
1123
+ if (retry.status === "pending_auth") return {
1124
+ id,
1125
+ store: {
1126
+ client: retry.client,
1127
+ configKey,
1128
+ status: "pending_auth",
1129
+ stderrBuffer: null,
1130
+ cliHint: retry.cliHint
1131
+ },
1132
+ serverStatus: {
1133
+ status: "pending_auth",
1134
+ stderr: "",
1135
+ cliHint: retry.cliHint
1136
+ },
1137
+ change: null,
1138
+ error: null,
1139
+ tools: null
1140
+ };
1141
+ if (retry.status === "timeout") return {
1142
+ id,
1143
+ store: {
1144
+ client: retry.client,
1145
+ configKey,
1146
+ status: "timeout",
1147
+ stderrBuffer: retry.stderrBuffer,
1148
+ cliHint: null
1149
+ },
1150
+ serverStatus: {
1151
+ status: "timeout",
1152
+ stderr: retry.stderr
1153
+ },
1154
+ change: null,
1155
+ error: null,
1156
+ tools: null
1157
+ };
1158
+ if (retry.status === "failed") return {
1159
+ id,
1160
+ store: null,
1161
+ serverStatus: {
1162
+ status: "failed",
1163
+ error: retry.error,
1164
+ stderr: retry.stderr
1165
+ },
1166
+ change: null,
1167
+ error: null,
1168
+ tools: null
1169
+ };
1170
+ connected = {
1171
+ client: retry.client,
1172
+ configKey,
1173
+ status: "connected",
1174
+ stderrBuffer: retry.stderr,
1175
+ cliHint: null
1176
+ };
1177
+ action = "refreshed";
1178
+ } else if (connected.status === "timeout") {
1179
+ const stderr = connected.stderrBuffer?.read() ?? "";
1180
+ return {
1181
+ id,
1182
+ store: connected,
1183
+ serverStatus: {
1184
+ status: "timeout",
1185
+ stderr
1186
+ },
1187
+ change: null,
1188
+ error: null,
1189
+ tools: null
1190
+ };
1191
+ }
1192
+ let mcpTools;
1193
+ try {
1194
+ mcpTools = (await connected.client.listTools()).tools;
1195
+ } catch (err) {
1196
+ const message = err instanceof Error ? err.message : String(err);
1197
+ const stderr = connected.stderrBuffer?.read() ?? "";
1198
+ return {
1199
+ id,
1200
+ store: connected,
1201
+ serverStatus: {
1202
+ status: "failed",
1203
+ error: message,
1204
+ stderr
1205
+ },
1206
+ change: null,
1207
+ error: {
1208
+ serverId: id,
1209
+ message
1210
+ },
1211
+ tools: null
1212
+ };
1213
+ }
1214
+ return {
1215
+ id,
1216
+ store: connected,
1217
+ serverStatus: { status: "connected" },
1218
+ change: action === "reused" ? null : action,
1219
+ error: null,
1220
+ tools: {
1221
+ client: connected.client,
1222
+ list: mcpTools
1223
+ }
1224
+ };
1225
+ }
1226
+ async reconcileAndRecordMtime({ pi, configPath, reason }) {
1227
+ const summary = await this.reconcile({
1228
+ pi,
1229
+ configPath
1230
+ });
1231
+ this.lastConfigMtimeMs = await readConfigMtimeMs(configPath);
1232
+ if (reason !== "session_start" && summaryHasChanges(summary)) this.pendingMcpUpdate = summary;
1233
+ log$12.info({
1234
+ event: "mcp_reconcile",
1235
+ reason,
1236
+ total_tools: summary.totalTools,
1237
+ added: summary.added,
1238
+ removed: summary.removed,
1239
+ refreshed: summary.refreshed,
1240
+ errors: summary.errors,
1241
+ queued_continuation: reason !== "session_start" && summaryHasChanges(summary)
1242
+ }, "MCP reconcile complete");
1243
+ return summary;
1244
+ }
1245
+ asExtensionFactory() {
1246
+ return (pi) => {
1247
+ pi.on("session_start", async (_event, ctx) => {
1248
+ const configPath = join(ctx.cwd, MCP_CONFIG_FILENAME);
1249
+ try {
1250
+ await this.reconcileAndRecordMtime({
1251
+ pi,
1252
+ configPath,
1253
+ reason: "session_start"
1254
+ });
1255
+ } catch (err) {
1256
+ log$12.error({
1257
+ err,
1258
+ event: "mcp_reconcile_failed"
1259
+ }, "MCP reconcile failed");
1260
+ }
1261
+ });
1262
+ pi.on("tool_result", async (_event, ctx) => {
1263
+ const configPath = join(ctx.cwd, MCP_CONFIG_FILENAME);
1264
+ let mtime;
1265
+ try {
1266
+ mtime = await readConfigMtimeMs(configPath);
1267
+ } catch (err) {
1268
+ log$12.warn({
1269
+ err,
1270
+ event: "mcp_mtime_check_failed"
1271
+ }, "mtime check on mcp.config.json failed");
1272
+ return;
1273
+ }
1274
+ if (mtime === this.lastConfigMtimeMs) return;
1275
+ try {
1276
+ await this.reconcileAndRecordMtime({
1277
+ pi,
1278
+ configPath,
1279
+ reason: "auto_reload"
1280
+ });
1281
+ } catch (err) {
1282
+ log$12.error({
1283
+ err,
1284
+ event: "mcp_auto_reload_failed"
1285
+ }, "auto-reload after mcp.config.json change failed");
1286
+ }
1287
+ });
1288
+ pi.registerTool({
1289
+ name: "reload_mcp",
1290
+ label: "Reload MCP servers (fallback)",
1291
+ description: "Re-read mcp.config.json and reconcile MCP server connections. You usually do NOT need to call this — edits to mcp.config.json are detected automatically and the harness injects a follow-up turn that picks up the new tool set. Call it explicitly only when the config didn't change but the environment did: e.g. you just `pip install`'d a Python package that an existing MCP server's command needs to spawn, and you want to retry the connection without touching the file. The harness will inject a continuation turn after this call so the new tool inventory is visible.",
1292
+ parameters: Type.Object({}),
1293
+ execute: async (_toolCallId, _params, _signal, _onUpdate, ctx) => {
1294
+ const configPath = join(ctx.cwd, MCP_CONFIG_FILENAME);
1295
+ try {
1296
+ const summary = await this.reconcileAndRecordMtime({
1297
+ pi,
1298
+ configPath,
1299
+ reason: "tool"
1300
+ });
1301
+ return {
1302
+ content: [{
1303
+ type: "text",
1304
+ text: summaryText(summary)
1305
+ }],
1306
+ details: {
1307
+ toolCount: summary.totalTools,
1308
+ summary
1309
+ }
1310
+ };
1311
+ } catch (err) {
1312
+ const message = err instanceof Error ? err.message : String(err);
1313
+ return {
1314
+ content: [{
1315
+ type: "text",
1316
+ text: `reload_mcp failed: ${message}`
1317
+ }],
1318
+ details: { error: message }
1319
+ };
1320
+ }
1321
+ }
1322
+ });
1323
+ };
1324
+ }
1325
+ };
1326
+ const defaultMcp = new McpExtension();
1327
+ defaultMcp.reconcile.bind(defaultMcp);
1328
+ const consumePendingMcpUpdate = defaultMcp.consumePendingUpdate.bind(defaultMcp);
1329
+ const hasPendingMcpUpdate = defaultMcp.hasPendingUpdate.bind(defaultMcp);
1330
+ var mcp_default = defaultMcp.asExtensionFactory();
1331
+ function pendingAuthEntries(summary) {
1332
+ return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "pending_auth" ? [{
1333
+ id,
1334
+ stderr: status.stderr,
1335
+ cliHint: status.cliHint
1336
+ }] : []);
1337
+ }
1338
+ function timeoutEntries(summary) {
1339
+ return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "timeout" ? [{
1340
+ id,
1341
+ stderr: status.stderr
1342
+ }] : []);
1343
+ }
1344
+ function failedEntries(summary) {
1345
+ return Object.entries(summary.servers).flatMap(([id, status]) => status.status === "failed" ? [{
1346
+ id,
1347
+ error: status.error,
1348
+ stderr: status.stderr
1349
+ }] : []);
1350
+ }
1351
+ function appendStderrBlock(lines, stderr) {
1352
+ if (!stderr) return;
1353
+ lines.push(" stderr:");
1354
+ lines.push(" ---");
1355
+ for (const line of stderr.trimEnd().split("\n")) lines.push(` ${line}`);
1356
+ lines.push(" ---");
1357
+ }
1358
+ function summaryText(summary) {
1359
+ const lines = [];
1360
+ lines.push(`MCP reconcile complete: ${summary.totalTools} tool(s) live.`);
1361
+ if (summary.added.length > 0) lines.push(` Added: ${summary.added.join(", ")}`);
1362
+ if (summary.refreshed.length > 0) lines.push(` Refreshed: ${summary.refreshed.join(", ")}`);
1363
+ if (summary.removed.length > 0) lines.push(` Removed: ${summary.removed.join(", ")}`);
1364
+ for (const { id, stderr, cliHint } of pendingAuthEntries(summary)) {
1365
+ lines.push(` WAITING ON AUTH ${id}:`);
1366
+ lines.push(` run \`${cliHint}\` in the shell to authenticate.`);
1367
+ appendStderrBlock(lines, stderr);
1368
+ }
1369
+ for (const { id, stderr } of timeoutEntries(summary)) {
1370
+ lines.push(` TIMED OUT ${id}:`);
1371
+ lines.push(` bridge spawned but did not complete initialize in time`);
1372
+ lines.push(` (usually mcp-remote mid-OAuth — see captured stderr).`);
1373
+ appendStderrBlock(lines, stderr);
1374
+ }
1375
+ for (const { id, error, stderr } of failedEntries(summary)) {
1376
+ lines.push(` FAILED ${id}: ${error}`);
1377
+ appendStderrBlock(lines, stderr);
1378
+ }
1379
+ for (const err of summary.errors) {
1380
+ if (summary.servers[err.serverId]?.status === "failed") continue;
1381
+ lines.push(` ERROR ${err.serverId}: ${err.message}`);
1382
+ }
1383
+ return lines.join("\n");
1384
+ }
1385
+ function summaryHasChanges(summary) {
1386
+ return summary.added.length > 0 || summary.removed.length > 0 || summary.refreshed.length > 0 || summary.errors.length > 0;
1387
+ }
1388
+ /**
1389
+ * Format a queued tool-update as a synthetic system-style message for
1390
+ * the harness to inject as a continuation prompt. The agent reads this
1391
+ * on its next turn (which has a fresh tool-snapshot) and acts on the
1392
+ * new set.
1393
+ */
1394
+ function formatMcpUpdateMessage(summary) {
1395
+ const lines = ["[system] Your MCP tool inventory changed during the previous turn. Your tool list is now updated; act on the new set rather than what was visible before."];
1396
+ if (summary.added.length > 0) lines.push(`Newly available servers: ${summary.added.join(", ")}`);
1397
+ if (summary.refreshed.length > 0) lines.push(`Refreshed servers: ${summary.refreshed.join(", ")}`);
1398
+ if (summary.removed.length > 0) lines.push(`Removed servers (and their tools): ${summary.removed.join(", ")}`);
1399
+ const pending = pendingAuthEntries(summary);
1400
+ if (pending.length > 0) {
1401
+ lines.push("");
1402
+ lines.push("Servers awaiting OAuth (http transport returned 401):");
1403
+ for (const { id, stderr, cliHint } of pending) {
1404
+ lines.push(` - ${id}:`);
1405
+ lines.push(` run \`${cliHint}\` to authenticate`);
1406
+ appendStderrBlock(lines, stderr);
1407
+ }
1408
+ lines.push("After authentication completes, call `reload_mcp` to pick up the now-connected tools — the harness won't reconcile on its own until you do.");
1409
+ }
1410
+ const timedOut = timeoutEntries(summary);
1411
+ if (timedOut.length > 0) {
1412
+ lines.push("");
1413
+ lines.push("Servers that timed out during initialize (bridge is still alive in the background; usually mcp-remote-style stdio bridges mid-OAuth):");
1414
+ for (const { id, stderr } of timedOut) {
1415
+ lines.push(` - ${id}:`);
1416
+ appendStderrBlock(lines, stderr);
1417
+ }
1418
+ lines.push("If the bridge prints an auth URL in its stderr, share it with the user. Then call `reload_mcp` once they've finished.");
1419
+ }
1420
+ const failed = failedEntries(summary);
1421
+ if (failed.length > 0) {
1422
+ lines.push("");
1423
+ lines.push("Servers that failed to start:");
1424
+ for (const { id, error, stderr } of failed) {
1425
+ lines.push(` - ${id}: ${error}`);
1426
+ appendStderrBlock(lines, stderr);
1427
+ }
1428
+ }
1429
+ const unstructuredErrors = summary.errors.filter((e) => summary.servers[e.serverId]?.status !== "failed");
1430
+ if (unstructuredErrors.length > 0) {
1431
+ lines.push("Errors:");
1432
+ for (const err of unstructuredErrors) lines.push(` - ${err.serverId}: ${err.message}`);
1433
+ }
1434
+ if (summary.added.filter((id) => summary.servers[id]?.status === "connected").length > 0) lines.push(CAPABILITY_SOUL_NUDGE);
1435
+ lines.push("Continue from where you left off, using the current tool list. Do not re-do work that already succeeded last turn.");
1436
+ return lines.join("\n");
1437
+ }
1438
+ //#endregion
1439
+ //#region src/tool-update-loop.ts
1440
+ const MAX_TOOL_UPDATE_CONTINUATIONS = 3;
1441
+ function installToolUpdateAutoStop({ session, log }) {
1442
+ const agent = session.agent;
1443
+ if (typeof agent.createLoopConfig !== "function") throw new Error("installToolUpdateAutoStop: session.agent.createLoopConfig is missing — pi-agent-core internals changed; update tool-update-loop.ts.");
1444
+ const original = agent.createLoopConfig.bind(agent);
1445
+ agent.createLoopConfig = (options) => {
1446
+ return {
1447
+ ...original(options),
1448
+ shouldStopAfterTurn: (ctx) => {
1449
+ if (ctx.message.stopReason === "error" || ctx.message.stopReason === "aborted") return false;
1450
+ const stop = hasPendingMcpUpdate() || hasPendingLocalToolsUpdate();
1451
+ if (stop) log.info({
1452
+ event: "tool_update_auto_stop_after_turn",
1453
+ mcp: hasPendingMcpUpdate(),
1454
+ local_tools: hasPendingLocalToolsUpdate()
1455
+ }, "ending turn so next prompt() snapshots refreshed tool list");
1456
+ return stop;
1457
+ }
1458
+ };
1459
+ };
1460
+ }
1461
+ async function runToolUpdateLoop({ session, log }) {
1462
+ for (let i = 0; i < MAX_TOOL_UPDATE_CONTINUATIONS; i++) {
1463
+ const mcpUpdate = consumePendingMcpUpdate();
1464
+ const localToolsUpdate = consumePendingLocalToolsUpdate();
1465
+ if (!mcpUpdate && !localToolsUpdate) return;
1466
+ const parts = [];
1467
+ if (mcpUpdate) parts.push(formatMcpUpdateMessage(mcpUpdate));
1468
+ if (localToolsUpdate) parts.push(formatLocalToolsUpdateMessage(localToolsUpdate));
1469
+ const content = parts.join("\n\n");
1470
+ log.info({
1471
+ event: "tool_update_continuation_injected",
1472
+ iteration: i + 1,
1473
+ mcp: mcpUpdate ? {
1474
+ added: mcpUpdate.added,
1475
+ removed: mcpUpdate.removed,
1476
+ refreshed: mcpUpdate.refreshed
1477
+ } : null,
1478
+ mcp_servers: mcpUpdate?.servers ?? null,
1479
+ local_tools: localToolsUpdate ? {
1480
+ added: localToolsUpdate.added,
1481
+ removed: localToolsUpdate.removed,
1482
+ refreshed: localToolsUpdate.refreshed
1483
+ } : null,
1484
+ formatted_content: content
1485
+ }, "injecting tool-update continuation prompt");
1486
+ await session.reload();
1487
+ await session.bindExtensions({});
1488
+ const customType = mcpUpdate && localToolsUpdate ? "tool-update" : mcpUpdate ? "mcp-tool-update" : "local-tools-update";
1489
+ await session.sendCustomMessage({
1490
+ customType,
1491
+ content,
1492
+ display: false,
1493
+ details: {
1494
+ mcp: mcpUpdate,
1495
+ localTools: localToolsUpdate
1496
+ }
1497
+ }, { triggerTurn: true });
1498
+ }
1499
+ if (consumePendingMcpUpdate() || consumePendingLocalToolsUpdate()) log.warn({
1500
+ event: "tool_update_continuation_capped",
1501
+ cap: MAX_TOOL_UPDATE_CONTINUATIONS
1502
+ }, "reached tool-update continuation cap; further updates will land on next request");
1503
+ }
1504
+ //#endregion
1505
+ //#region src/tracing.ts
1506
+ const SERVICE_NAME = "skydive-agent-harness";
1507
+ const DAEMON_TRACES_URL = "http://localhost:38994/v1/traces";
1508
+ let provider = null;
1509
+ function initTracing() {
1510
+ if (provider) return;
1511
+ propagation.setGlobalPropagator(new W3CTraceContextPropagator());
1512
+ provider = new NodeTracerProvider({ resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME }) });
1513
+ const exporter = new OTLPTraceExporter({ url: DAEMON_TRACES_URL });
1514
+ provider.addSpanProcessor(new BatchSpanProcessor(exporter, { scheduledDelayMillis: 1e3 }));
1515
+ provider.register();
1516
+ logger.info({
1517
+ event: "tracing_enabled",
1518
+ endpoint: DAEMON_TRACES_URL
1519
+ }, "OTel tracing initialized — exporting via daemon");
1520
+ const shutdown = async () => {
1521
+ await shutdownTracing();
1522
+ process.exit(0);
1523
+ };
1524
+ process.on("SIGTERM", shutdown);
1525
+ process.on("SIGINT", shutdown);
1526
+ }
1527
+ function getTracer() {
1528
+ return trace.getTracer(SERVICE_NAME);
1529
+ }
1530
+ function extractRemoteContext() {
1531
+ const traceparent = getCurrentTraceparent();
1532
+ if (!traceparent) return ROOT_CONTEXT;
1533
+ const carrier = { traceparent };
1534
+ return propagation.extract(ROOT_CONTEXT, carrier, {
1535
+ get: (c, key) => c[key],
1536
+ keys: (c) => Object.keys(c)
1537
+ });
1538
+ }
1539
+ async function shutdownTracing() {
1540
+ if (provider) await provider.shutdown();
1541
+ }
1542
+ //#endregion
1543
+ //#region src/harness.ts
1544
+ /**
1545
+ * Skydive composition over @skydiveai/pi-server: wires the platform
1546
+ * defaults (tracing to the daemon, tool-update hot-reload hooks, prewarm
1547
+ * paths, header passthrough for proxy routing, Skydive agent-card
1548
+ * branding) into the generic protocol server, and returns mountable
1549
+ * handlers. The agent supplies the pi session factory (their session.ts)
1550
+ * and owns the express app:
1551
+ *
1552
+ * const { platform, protocols } = createHarness({
1553
+ * cwd: process.cwd(),
1554
+ * createSession,
1555
+ * });
1556
+ * app.get('/health', platform.handlers.health);
1557
+ * app.use(platform.handlers.injectEnv);
1558
+ * app.use(platform.handlers.prewarm); // matches /_skydive/prewarm + legacy alias
1559
+ * app.use(protocols.handlers.all);
1560
+ */
1561
+ const A2A_PATH = "/a2a";
1562
+ const AGENT_CARD_PATH = "/.well-known/agent-card.json";
1563
+ const PREWARM_PATHS = ["/_skydive/prewarm", "/_anyone/prewarm"];
1564
+ const PASSTHROUGH_HEADER_PREFIXES = ["x-anyone-", "x-skydive-"];
1565
+ function createHarness(options) {
1566
+ initTracing();
1567
+ readPlatformVersions().then((runtimeVersions) => logger.info({
1568
+ event: "runtime_versions",
1569
+ runtimeVersions
1570
+ }, "platform runtime versions"));
1571
+ const serverOptions = {
1572
+ ...options,
1573
+ onSessionSetup: options.onSessionSetup ?? ((args) => {
1574
+ installToolUpdateAutoStop(args);
1575
+ installIterationCap(args);
1576
+ }),
1577
+ postPrompt: options.postPrompt ?? runToolUpdateLoop,
1578
+ passthroughHeaderPrefixes: options.passthroughHeaderPrefixes ?? PASSTHROUGH_HEADER_PREFIXES,
1579
+ prewarmPaths: options.prewarmPaths ?? PREWARM_PATHS
1580
+ };
1581
+ const webHandlers = createProtocolHandlers(serverOptions);
1582
+ const cardOverrides = {
1583
+ name: "Skydive Agent",
1584
+ description: "An AI coding agent powered by the Skydive platform.",
1585
+ ...options.agentCard
1586
+ };
1587
+ const agentCardWeb = async (request) => {
1588
+ const url = new URL(request.url);
1589
+ if (request.method !== "GET" || url.pathname !== AGENT_CARD_PATH) return null;
1590
+ return Response.json(buildAgentCard(cardOverrides.url ?? `${url.origin}${A2A_PATH}`, cardOverrides));
1591
+ };
1592
+ const a2a = restHandler({
1593
+ requestHandler: new DefaultRequestHandler(buildAgentCard(cardOverrides.url ?? A2A_PATH, cardOverrides), new InMemoryTaskStore(), createAgentExecutor(serverOptions), new DefaultExecutionEventBusManager()),
1594
+ userBuilder: UserBuilder.noAuthentication
1595
+ });
1596
+ return {
1597
+ platform: { handlers: {
1598
+ /**
1599
+ * Platform health plus the agent's `healthMetadata`. Mount above
1600
+ * injectEnv — health must respond immediately for prewarm
1601
+ * stashing and readiness probes, and injectEnv can wait up to
1602
+ * 10s for env vars during boot.
1603
+ */
1604
+ health: createHealthHandler({ metadata: options.healthMetadata ?? null }),
1605
+ /** Loads platform env (e2b envd / daemon long-poll). */
1606
+ injectEnv: createPlatformEnvMiddleware(),
1607
+ prewarm: webHandlerToMiddleware(webHandlers.prewarm)
1608
+ } },
1609
+ protocols: { handlers: {
1610
+ /** Express-style; mount at /a2a. */
1611
+ a2a,
1612
+ /** GET /.well-known/agent-card.json. */
1613
+ agentCard: webHandlerToMiddleware(agentCardWeb),
1614
+ /** Mirrors each vendor's API shape. */
1615
+ openai: { v1: {
1616
+ chat: { completions: webHandlerToMiddleware(webHandlers.chatCompletions) },
1617
+ responses: webHandlerToMiddleware(webHandlers.responses)
1618
+ } },
1619
+ anthropic: { v1: { messages: webHandlerToMiddleware(webHandlers.messages) } },
1620
+ /**
1621
+ * Everything in one mount: a2a (+ agent card) at their well-known
1622
+ * paths, then chat-completions / anthropic-messages / responses.
1623
+ * Calls next() when nothing matches.
1624
+ */
1625
+ all: chainMiddleware([mountAt(A2A_PATH, a2a), webHandlerToMiddleware(composeHandlers([
1626
+ agentCardWeb,
1627
+ webHandlers.chatCompletions,
1628
+ webHandlers.messages,
1629
+ webHandlers.responses
1630
+ ]))])
1631
+ } }
1632
+ };
1633
+ }
1634
+ /** Effective bash timeout: the model's value when it gave a positive number, else the default. */
1635
+ function resolveBashTimeout(provided) {
1636
+ return typeof provided === "number" && provided > 0 ? provided : 600;
1637
+ }
1638
+ const bashDefaultTimeoutExtension = (pi) => {
1639
+ pi.on("tool_call", async (event) => {
1640
+ if (event.toolName !== "bash") return;
1641
+ event.input.timeout = resolveBashTimeout(event.input.timeout);
1642
+ });
1643
+ };
1644
+ //#endregion
1645
+ //#region src/channel-context-ref.ts
1646
+ /**
1647
+ * The worker injects only a reference — `{ channel, messageId }` — into the
1648
+ * sandbox env (`SKYDIVE_CHANNEL_CONTEXT`) rather than the full context.
1649
+ *
1650
+ * The canonical `ChannelContextRef` type + `parseChannelContextRef` live in
1651
+ * `@createinc/anyone-channels`, but the harness (`@skydiveai/*`) keeps zero
1652
+ * `@createinc/*` dependencies — importing that package would pull the whole
1653
+ * platform channel stack (Slack/email/Linq SDKs, messaging) in just to read
1654
+ * two fields. So we validate the (stable) shape locally instead.
1655
+ */
1656
+ const channelContextRefSchema = z.object({ messageId: z.string().nullable() });
1657
+ /**
1658
+ * Pull the current turn's message id out of the ref blob. Returns `null`
1659
+ * outside a turn (cron / system) or if the blob is missing/malformed.
1660
+ */
1661
+ function extractMessageId(channelContextJson) {
1662
+ if (!channelContextJson) return null;
1663
+ try {
1664
+ const parsed = channelContextRefSchema.safeParse(JSON.parse(channelContextJson));
1665
+ return parsed.success ? parsed.data.messageId : null;
1666
+ } catch {
1667
+ return null;
1668
+ }
1669
+ }
1670
+ //#endregion
1671
+ //#region src/api-url.ts
1672
+ /**
1673
+ * Resolve the Skydive API base URL from the sandbox env. Newly-provisioned
1674
+ * sandboxes get `ANYONE_API_URL` (see `apps/anyone/infra` stack); `SKYDIVE_API_URL`
1675
+ * is the legacy name that only already-provisioned sandboxes still carry, and
1676
+ * it won't be injected going forward. We check the legacy name first (matching
1677
+ * the other sandbox env readers, e.g. `platform.ts` and `anyone-platform-cli`)
1678
+ * and fall back to the current one. Returns `null` when neither is set (e.g.
1679
+ * local dev with no sandbox), which callers treat as "API unavailable".
1680
+ */
1681
+ function apiBaseUrl() {
1682
+ return process.env.SKYDIVE_API_URL ?? process.env.ANYONE_API_URL ?? null;
1683
+ }
1684
+ //#endregion
1685
+ //#region src/extensions/platform.ts
1686
+ /**
1687
+ * Platform extension — bridges the agent harness to the Skydive platform daemon.
1688
+ *
1689
+ * Responsibilities:
1690
+ * - Heartbeat: periodic POST to the API so the sandbox manager knows the
1691
+ * agent is alive. Throttled to once per minute, triggered by tool events.
1692
+ * - Session tracking: registers the session with the daemon on start,
1693
+ * streams tool_call / tool_result events so the daemon can track which
1694
+ * session is actively executing, and signals session end on agent_end.
1695
+ * - Channel context: passes the SKYDIVE_CHANNEL_CONTEXT (containing the
1696
+ * messageId) to the daemon so file writes can be attributed to the
1697
+ * correct conversation.
1698
+ *
1699
+ * All daemon POSTs are fire-and-forget — failures are logged but never
1700
+ * block the agent. The daemon may not be running (e.g. local dev without
1701
+ * a sandbox), and that's fine.
1702
+ */
1703
+ const HEARTBEAT_THROTTLE_MS = 6e4;
1704
+ const TOOL_HEARTBEAT_INTERVAL_MS = 5e3;
1705
+ const MAX_TOOL_HEARTBEATS = 1440 * 60 * 1e3 / TOOL_HEARTBEAT_INTERVAL_MS;
1706
+ const DAEMON_URL = "http://localhost:38994";
1707
+ const log$11 = logger.child({ module: "platform-ext" });
1708
+ function sandboxClient() {
1709
+ const apiUrl = apiBaseUrl();
1710
+ if (!apiUrl) return null;
1711
+ return hc(`${apiUrl}/api/v1/sandbox`);
1712
+ }
1713
+ /**
1714
+ * Is this box still an unclaimed warm-pool sandbox? (ANY-6000, the
1715
+ * feature-flags half of the ANY-5184 pool 403 wave.)
1716
+ *
1717
+ * `GET /sandbox/feature-flags` is agent-only, so the shared poller's request
1718
+ * from a pool box can only 403 — a guaranteed-failing GET every 60s for the
1719
+ * life of the pool phase. The discriminator is the sandbox token's `type`
1720
+ * claim, read UNVERIFIED (this box never holds the signing secret): not an
1721
+ * authorization decision, only "should I bother calling?", and the api still
1722
+ * authorizes every request.
1723
+ *
1724
+ * Read per call from the daemon's persisted env file, NOT process.env:
1725
+ * claiming a pool box rebinds the token in place (the daemon rewrites this
1726
+ * file) while the harness's process.env keeps the boot snapshot, so a
1727
+ * process-env gate would leave a claimed box permanently skipping — trading a
1728
+ * wasted request for silently frozen flags, which is strictly worse. "Cannot
1729
+ * tell" (no file, no token, unparseable payload) reports false so the poll
1730
+ * proceeds.
1731
+ */
1732
+ const daemonEnvIdentitySchema = z.object({
1733
+ ANYONE_SANDBOX_TOKEN: z.string().optional(),
1734
+ SKYDIVE_SANDBOX_TOKEN: z.string().optional()
1735
+ }).passthrough();
1736
+ const tokenTypeSchema = z.object({ type: z.string() }).passthrough();
1737
+ async function isPoolIdentity() {
1738
+ try {
1739
+ const envFile = process.env.ANYONE_DAEMON_ENV_CACHE ?? "/tmp/.anyone/daemon-env.json";
1740
+ const env = daemonEnvIdentitySchema.safeParse(JSON.parse(await readFile(envFile, "utf8")));
1741
+ if (!env.success) return false;
1742
+ const token = env.data.ANYONE_SANDBOX_TOKEN ?? env.data.SKYDIVE_SANDBOX_TOKEN;
1743
+ if (typeof token !== "string" || token === "") return false;
1744
+ const payload = token.split(".")[1];
1745
+ if (!payload) return false;
1746
+ const claims = tokenTypeSchema.safeParse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
1747
+ return claims.success && claims.data.type === "onboarding-pool";
1748
+ } catch (_err) {
1749
+ return false;
1750
+ }
1751
+ }
1752
+ /**
1753
+ * Fetch every harness feature flag in one GET (`{ contextManagement, ... }`
1754
+ * — see apps/anyone/api/src/routes/sandbox-feature-flags.ts). Returns
1755
+ * null when indeterminate (no api url, the request failed, or the box is an
1756
+ * unclaimed pool sandbox whose token the route would 403) so the shared
1757
+ * poller keeps the last-known values rather than flipping on a transient error.
1758
+ * This is the single fetch behind `feature-flags-poll.ts`; extensions read the
1759
+ * polled values there instead of issuing their own GET.
1760
+ */
1761
+ async function fetchHarnessFlags() {
1762
+ const client = sandboxClient();
1763
+ if (!client) return null;
1764
+ if (await isPoolIdentity()) return null;
1765
+ try {
1766
+ const res = await client["feature-flags"].$get();
1767
+ if (!res.ok) {
1768
+ log$11.debug({
1769
+ status: res.status,
1770
+ event: "feature_flags_fetch_failed"
1771
+ }, "feature-flags fetch failed");
1772
+ return null;
1773
+ }
1774
+ return { contextManagement: (await res.json()).contextManagement ?? null };
1775
+ } catch (err) {
1776
+ log$11.debug({
1777
+ err,
1778
+ event: "feature_flags_fetch_error"
1779
+ }, "feature-flags request errored");
1780
+ return null;
1781
+ }
1782
+ }
1783
+ function postHeartbeat({ messageId }) {
1784
+ const client = sandboxClient();
1785
+ if (!client) return;
1786
+ client.heartbeat.$post({ json: { messageId } }).catch((err) => {
1787
+ log$11.debug({
1788
+ err,
1789
+ event: "heartbeat_failed"
1790
+ }, "heartbeat failed");
1791
+ });
1792
+ }
1793
+ async function resolveConversationFromApi(messageId) {
1794
+ const client = sandboxClient();
1795
+ if (!client) return null;
1796
+ try {
1797
+ const res = await client["message-conversation"].$get({ query: { messageId } });
1798
+ if (!res.ok) {
1799
+ log$11.warn({
1800
+ status: res.status,
1801
+ messageId,
1802
+ event: "resolve_conversation_failed"
1803
+ }, "resolve conversation failed");
1804
+ return null;
1805
+ }
1806
+ return (await res.json()).conversationId ?? null;
1807
+ } catch (err) {
1808
+ log$11.warn({
1809
+ err,
1810
+ messageId,
1811
+ event: "resolve_conversation_error"
1812
+ }, "resolve conversation request errored");
1813
+ return null;
1814
+ }
1815
+ }
1816
+ async function postBackgroundTaskDone({ messageId, content }) {
1817
+ const client = sandboxClient();
1818
+ if (!client) throw new Error("no api url for bg-task-done");
1819
+ const res = await client["bg-task-done"].$post({ json: {
1820
+ messageId,
1821
+ content
1822
+ } });
1823
+ if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
1824
+ }
1825
+ async function postSubagentSpawn({ messageId, tasks }) {
1826
+ const client = sandboxClient();
1827
+ if (!client) throw new Error("no api url for subagent-spawn");
1828
+ const res = await client["subagent-spawn"].$post({ json: {
1829
+ messageId,
1830
+ tasks
1831
+ } });
1832
+ if (!res.ok) {
1833
+ let detail = "";
1834
+ try {
1835
+ const errBody = await res.json();
1836
+ if (errBody && typeof errBody.error === "string") detail = `: ${errBody.error}`;
1837
+ } catch {}
1838
+ throw new Error(`subagent-spawn POST failed (${res.status})${detail}`);
1839
+ }
1840
+ const body = await res.json();
1841
+ return {
1842
+ taskIds: body.taskIds,
1843
+ tasks: body.tasks ?? []
1844
+ };
1845
+ }
1846
+ function createHeartbeatThrottle({ messageId }) {
1847
+ let lastAt = 0;
1848
+ let pending = null;
1849
+ function send() {
1850
+ const now = Date.now();
1851
+ if (now - lastAt < HEARTBEAT_THROTTLE_MS) {
1852
+ if (!pending) pending = setTimeout(() => {
1853
+ pending = null;
1854
+ send();
1855
+ }, HEARTBEAT_THROTTLE_MS - (now - lastAt));
1856
+ return;
1857
+ }
1858
+ lastAt = now;
1859
+ postHeartbeat({ messageId });
1860
+ }
1861
+ function cancel() {
1862
+ if (pending) {
1863
+ clearTimeout(pending);
1864
+ pending = null;
1865
+ }
1866
+ }
1867
+ return {
1868
+ send,
1869
+ cancel
1870
+ };
1871
+ }
1872
+ function createToolHeartbeat({ messageId }) {
1873
+ const activeToolCalls = /* @__PURE__ */ new Set();
1874
+ let interval = null;
1875
+ let heartbeatCount = 0;
1876
+ function stop() {
1877
+ if (interval) {
1878
+ clearInterval(interval);
1879
+ interval = null;
1880
+ }
1881
+ heartbeatCount = 0;
1882
+ }
1883
+ function start() {
1884
+ if (interval) return;
1885
+ heartbeatCount = 0;
1886
+ interval = setInterval(() => {
1887
+ if (activeToolCalls.size === 0) {
1888
+ stop();
1889
+ return;
1890
+ }
1891
+ heartbeatCount++;
1892
+ if (heartbeatCount > MAX_TOOL_HEARTBEATS) {
1893
+ log$11.warn({
1894
+ heartbeatCount,
1895
+ activeToolCalls: [...activeToolCalls]
1896
+ }, "tool heartbeat max reached, stopping");
1897
+ stop();
1898
+ return;
1899
+ }
1900
+ postHeartbeat({ messageId });
1901
+ }, TOOL_HEARTBEAT_INTERVAL_MS);
1902
+ }
1903
+ return {
1904
+ onToolStart(toolCallId) {
1905
+ activeToolCalls.add(toolCallId);
1906
+ start();
1907
+ },
1908
+ onToolEnd(toolCallId) {
1909
+ activeToolCalls.delete(toolCallId);
1910
+ if (activeToolCalls.size === 0) stop();
1911
+ },
1912
+ stop,
1913
+ get activeCount() {
1914
+ return activeToolCalls.size;
1915
+ }
1916
+ };
1917
+ }
1918
+ function postToDaemon(path, body) {
1919
+ fetch(`${DAEMON_URL}${path}`, {
1920
+ method: "POST",
1921
+ headers: { "content-type": "application/json" },
1922
+ body: JSON.stringify(body)
1923
+ }).catch((err) => {
1924
+ log$11.debug({
1925
+ err,
1926
+ path,
1927
+ event: "daemon_post_failed"
1928
+ }, "daemon POST failed");
1929
+ });
1930
+ }
1931
+ function createPlatformExtensions({ sessionId, channelContext }) {
1932
+ return (pi) => {
1933
+ log$11.info({
1934
+ sessionId,
1935
+ hasChannelContext: Boolean(channelContext)
1936
+ }, "platform extension initialized");
1937
+ const messageId = extractMessageId(channelContext);
1938
+ const throttle = createHeartbeatThrottle({ messageId });
1939
+ const deferHeartbeat = () => {
1940
+ setTimeout(throttle.send, 0);
1941
+ };
1942
+ pi.on("agent_start", deferHeartbeat);
1943
+ pi.on("tool_execution_start", deferHeartbeat);
1944
+ pi.on("tool_execution_end", deferHeartbeat);
1945
+ pi.on("tool_execution_update", deferHeartbeat);
1946
+ pi.on("agent_end", deferHeartbeat);
1947
+ const toolHeartbeat = createToolHeartbeat({ messageId });
1948
+ pi.on("tool_execution_start", (event) => {
1949
+ toolHeartbeat.onToolStart(event.toolCallId);
1950
+ });
1951
+ pi.on("tool_execution_end", (event) => {
1952
+ toolHeartbeat.onToolEnd(event.toolCallId);
1953
+ });
1954
+ pi.on("agent_end", () => {
1955
+ toolHeartbeat.stop();
1956
+ throttle.cancel();
1957
+ });
1958
+ postToDaemon("/session", {
1959
+ sessionId,
1960
+ channelContext
1961
+ });
1962
+ pi.on("tool_call", (event) => {
1963
+ postToDaemon("/session/tool-event", {
1964
+ sessionId,
1965
+ kind: "tool_call",
1966
+ toolName: event.toolName,
1967
+ toolCallId: event.toolCallId,
1968
+ input: event.input
1969
+ });
1970
+ });
1971
+ pi.on("tool_result", (event) => {
1972
+ postToDaemon("/session/tool-event", {
1973
+ sessionId,
1974
+ kind: "tool_result",
1975
+ toolName: event.toolName,
1976
+ toolCallId: event.toolCallId,
1977
+ isError: event.isError,
1978
+ input: event.input
1979
+ });
1980
+ });
1981
+ pi.on("agent_end", () => {
1982
+ log$11.info({ sessionId }, "session ending");
1983
+ postToDaemon("/session/end", { sessionId });
1984
+ });
1985
+ };
1986
+ }
1987
+ //#endregion
1988
+ //#region src/extensions/feature-flags-poll.ts
1989
+ /**
1990
+ * Shared harness feature-flag poll.
1991
+ *
1992
+ * The api exposes one `/feature-flags` GET that returns every harness flag in a
1993
+ * single response (`{ contextManagement, commandFlags }` — see
1994
+ * apps/anyone/api/src/routes/sandbox-feature-flags.ts). Rather than each
1995
+ * extension issuing its own GET — and, worse, a *blocking* GET on the
1996
+ * pre-first-token `session_start` path — a single background poller fetches
1997
+ * that response once per interval and fans the values out to every subscriber.
1998
+ *
1999
+ * Why one poller: context-management consumes the `contextManagement` flag
2000
+ * without a blocking GET on the pre-first-token `session_start` path. Reading
2001
+ * the last-polled value keeps the hot path allocation-only; a cold cache reads
2002
+ * as `null` and a newly-flipped flag takes effect on the next poll.
2003
+ *
2004
+ * The poll is fire-and-forget and self-unref'd — it never keeps the process
2005
+ * alive and an indeterminate result (no api url / transient failure) leaves the
2006
+ * last-known values untouched so a blip can't silently flip behavior.
2007
+ */
2008
+ const log$10 = logger.child({ module: "feature-flags-poll" });
2009
+ const FLAG_POLL_INTERVAL_MS = 6e4;
2010
+ let contextManagement = null;
2011
+ const subscribers = { contextManagement: /* @__PURE__ */ new Set() };
2012
+ let pollerStarted = false;
2013
+ let firstPollSettled = false;
2014
+ let resolveFirstPoll = null;
2015
+ new Promise((resolve) => {
2016
+ resolveFirstPoll = resolve;
2017
+ });
2018
+ function markFirstPollSettled() {
2019
+ if (firstPollSettled) return;
2020
+ firstPollSettled = true;
2021
+ resolveFirstPoll?.();
2022
+ }
2023
+ /** Last-polled value of a flag, or `null` if not yet resolved. */
2024
+ function getPolledFlag(_name) {
2025
+ return contextManagement;
2026
+ }
2027
+ /**
2028
+ * Subscribe to changes of a flag. The callback fires only on a *transition*
2029
+ * (skipped while the value is unchanged), so a subscriber registered before the
2030
+ * first poll still learns the initial value. Returns an unsubscribe fn.
2031
+ */
2032
+ function onFlagChange(name, cb) {
2033
+ subscribers[name].add(cb);
2034
+ return () => subscribers[name].delete(cb);
2035
+ }
2036
+ function apply(name, next) {
2037
+ if (next === null) return;
2038
+ const prev = contextManagement;
2039
+ contextManagement = next;
2040
+ if (next !== prev) for (const cb of subscribers[name]) try {
2041
+ cb(next);
2042
+ } catch (err) {
2043
+ log$10.warn({
2044
+ err,
2045
+ flag: name
2046
+ }, "flag subscriber threw");
2047
+ }
2048
+ }
2049
+ async function pollOnce() {
2050
+ try {
2051
+ const flags = await fetchHarnessFlags();
2052
+ if (!flags) return;
2053
+ apply("contextManagement", flags.contextManagement ?? null);
2054
+ } catch (err) {
2055
+ log$10.debug({ err }, "feature-flag poll threw");
2056
+ }
2057
+ }
2058
+ /**
2059
+ * Start the shared background poll (idempotent). No-op when there's no
2060
+ * phone-home channel (bare CLI): there's nothing to poll and callers keep their
2061
+ * env/boot default. Kicks an immediate poll, then repeats on an interval that
2062
+ * does not keep the process alive.
2063
+ */
2064
+ function startFeatureFlagPoller() {
2065
+ if (pollerStarted || !hasFlagSource()) return;
2066
+ pollerStarted = true;
2067
+ pollOnce().finally(markFirstPollSettled);
2068
+ setInterval(() => void pollOnce(), FLAG_POLL_INTERVAL_MS).unref?.();
2069
+ }
2070
+ //#endregion
2071
+ //#region src/extensions/context-management-trim.ts
2072
+ const CLEARED_PLACEHOLDER = "[old tool result cleared to save context — re-run the tool or re-read the source to recover it]";
2073
+ /** Rough token estimate (~4 chars/token); good enough for trigger decisions. */
2074
+ function estimateTokens(text) {
2075
+ return Math.ceil(text.length / 4);
2076
+ }
2077
+ function isToolResult(message) {
2078
+ return message.role === "toolResult";
2079
+ }
2080
+ function isTextBlock(block) {
2081
+ return block.type === "text";
2082
+ }
2083
+ function joinText(content) {
2084
+ return content.filter(isTextBlock).map((block) => block.text).join("");
2085
+ }
2086
+ function isCleared(content) {
2087
+ const first = content[0];
2088
+ return content.length === 1 && first !== void 0 && first.type === "text" && first.text === CLEARED_PLACEHOLDER;
2089
+ }
2090
+ /**
2091
+ * Returns a head/tail excerpt of `text` that fits within `maxBytes`, or null
2092
+ * if `text` is already within budget. Idempotent: the excerpt itself is within
2093
+ * budget, so re-running yields null.
2094
+ */
2095
+ function excerpt(text, maxBytes) {
2096
+ if (Buffer.byteLength(text) <= maxBytes) return null;
2097
+ const removedNotice = (removed) => `\n\n…[${removed} bytes truncated — re-run the tool or re-read the source for the full output]…\n\n`;
2098
+ const sample = removedNotice(Buffer.byteLength(text));
2099
+ const noticeBytes = Buffer.byteLength(sample);
2100
+ const budget = Math.max(maxBytes - noticeBytes, 0);
2101
+ if (budget < 64) return text.slice(0, Math.max(maxBytes, 0));
2102
+ const headChars = Math.floor(budget * .6);
2103
+ const tailChars = Math.floor(budget * .3);
2104
+ const head = text.slice(0, headChars);
2105
+ const tail = text.slice(text.length - tailChars);
2106
+ return `${head}${removedNotice(Buffer.byteLength(text) - Buffer.byteLength(head) - Buffer.byteLength(tail))}${tail}`;
2107
+ }
2108
+ /**
2109
+ * Transforms the message list in place (callers pass a clone) applying L1
2110
+ * insertion trimming and L2/L3 microcompact. Never throws.
2111
+ */
2112
+ function transformContextMessages(messages, config, now) {
2113
+ const stats = {
2114
+ trimmedResults: 0,
2115
+ trimmedTokens: 0,
2116
+ clearedResults: 0,
2117
+ clearedTokens: 0,
2118
+ clearTrigger: "none",
2119
+ remainingToolTokens: 0
2120
+ };
2121
+ const excluded = new Set(config.excludeTools);
2122
+ const toolResults = messages.filter((message) => isToolResult(message) && !excluded.has(message.toolName));
2123
+ for (const result of toolResults) {
2124
+ if (isCleared(result.content)) continue;
2125
+ const joined = joinText(result.content);
2126
+ const trimmed = excerpt(joined, config.perResultMaxBytes);
2127
+ if (trimmed === null) continue;
2128
+ const images = result.content.filter((block) => block.type === "image");
2129
+ result.content = [{
2130
+ type: "text",
2131
+ text: trimmed
2132
+ }, ...images];
2133
+ stats.trimmedResults += 1;
2134
+ stats.trimmedTokens += estimateTokens(joined) - estimateTokens(trimmed);
2135
+ }
2136
+ const clearable = toolResults.filter((result) => !isCleared(result.content)).slice(0, Math.max(toolResults.length - config.keepRecentToolResults, 0));
2137
+ const clearableTokens = clearable.reduce((sum, result) => sum + estimateTokens(joinText(result.content)), 0);
2138
+ const lastActivity = messages.reduce((max, message) => {
2139
+ if (message.role !== "assistant" && message.role !== "toolResult") return max;
2140
+ return Math.max(max, message.timestamp ?? 0);
2141
+ }, 0);
2142
+ const isCold = lastActivity > 0 && now - lastActivity > config.coldCacheGapSeconds * 1e3;
2143
+ if (clearable.length > 0 && (isCold || clearableTokens >= config.warmClearTriggerTokens && clearableTokens >= config.clearAtLeastTokens)) {
2144
+ stats.clearTrigger = isCold ? "cold" : "warm";
2145
+ for (const result of clearable) {
2146
+ stats.clearedTokens += estimateTokens(joinText(result.content));
2147
+ result.content = [{
2148
+ type: "text",
2149
+ text: CLEARED_PLACEHOLDER
2150
+ }];
2151
+ stats.clearedResults += 1;
2152
+ }
2153
+ }
2154
+ stats.remainingToolTokens = toolResults.reduce((sum, result) => sum + estimateTokens(joinText(result.content)), 0);
2155
+ return {
2156
+ messages,
2157
+ stats
2158
+ };
2159
+ }
2160
+ //#endregion
2161
+ //#region src/extensions/context-management.ts
2162
+ const log$9 = logger.child({ module: "context-management-extension" });
2163
+ function isAnthropicMessagesPayload(payload) {
2164
+ if (typeof payload !== "object" || payload === null) return false;
2165
+ const candidate = payload;
2166
+ return typeof candidate.model === "string" && candidate.model.includes("claude") && Array.isArray(candidate.messages);
2167
+ }
2168
+ function buildAnthropicEdits(config) {
2169
+ return [{
2170
+ type: "clear_tool_uses_20250919",
2171
+ trigger: {
2172
+ type: "input_tokens",
2173
+ value: config.warmClearTriggerTokens
2174
+ },
2175
+ keep: {
2176
+ type: "tool_uses",
2177
+ value: config.keepRecentToolResults
2178
+ },
2179
+ clear_at_least: {
2180
+ type: "input_tokens",
2181
+ value: config.clearAtLeastTokens
2182
+ },
2183
+ ...config.excludeTools.length > 0 ? { exclude_tools: config.excludeTools } : {}
2184
+ }, { type: "clear_thinking_20251015" }];
2185
+ }
2186
+ /**
2187
+ * L4 body-field injection (pure). Returns the payload untouched unless the
2188
+ * feature is on, native edits are enabled, the payload is an Anthropic messages
2189
+ * request, and it doesn't already carry a `context_management` field.
2190
+ */
2191
+ function applyNativeAnthropicEdits(payload, config) {
2192
+ if (!config.enabled || !config.nativeAnthropicEdits) return payload;
2193
+ if (!isAnthropicMessagesPayload(payload)) return payload;
2194
+ if (payload.context_management !== void 0) return payload;
2195
+ return {
2196
+ ...payload,
2197
+ context_management: { edits: buildAnthropicEdits(config) }
2198
+ };
2199
+ }
2200
+ /**
2201
+ * L1–L3 transform behind the enabled gate (pure). When disabled, returns the
2202
+ * messages untouched and `stats: null`.
2203
+ */
2204
+ function transformContextIfEnabled(messages, config, now) {
2205
+ if (!config.enabled) return {
2206
+ messages,
2207
+ stats: null
2208
+ };
2209
+ const result = transformContextMessages(messages, config, now);
2210
+ return {
2211
+ messages: result.messages,
2212
+ stats: result.stats
2213
+ };
2214
+ }
2215
+ /**
2216
+ * Builds the context-management extension. Reads the live (override-aware)
2217
+ * config from the runtime holder on every call, so the platform flag can flip
2218
+ * the feature on/off mid-session.
2219
+ */
2220
+ function createContextManagementExtension() {
2221
+ return (pi) => {
2222
+ const initial = getContextManagementConfig();
2223
+ if (!initial.enabled && !hasFlagSource()) return;
2224
+ setContextManagementFlagOverride(getPolledFlag("contextManagement"));
2225
+ onFlagChange("contextManagement", (enabled) => {
2226
+ setContextManagementFlagOverride(enabled);
2227
+ log$9.info({
2228
+ event: "context_management_flag_update",
2229
+ enabled
2230
+ }, "context-management flag updated from platform");
2231
+ });
2232
+ startFeatureFlagPoller();
2233
+ log$9.info({
2234
+ event: "context_management_registered",
2235
+ enabled: initial.enabled,
2236
+ flagSource: hasFlagSource(),
2237
+ perResultMaxBytes: initial.perResultMaxBytes,
2238
+ keepRecentToolResults: initial.keepRecentToolResults,
2239
+ nativeAnthropicEdits: initial.nativeAnthropicEdits
2240
+ }, "context-management handlers registered");
2241
+ pi.on("context", (event) => {
2242
+ const { messages } = event;
2243
+ try {
2244
+ const result = transformContextIfEnabled(messages, getContextManagementConfig(), Date.now());
2245
+ if (result.stats && (result.stats.clearedResults > 0 || result.stats.trimmedResults > 0)) log$9.info({
2246
+ event: "context_management_applied",
2247
+ ...result.stats
2248
+ }, "trimmed/cleared tool output before LLM call");
2249
+ return { messages: result.messages };
2250
+ } catch (err) {
2251
+ log$9.error({
2252
+ err,
2253
+ event: "context_management_transform_failed"
2254
+ }, "context transform failed; passing messages through unchanged");
2255
+ return { messages };
2256
+ }
2257
+ });
2258
+ pi.on("before_provider_request", (event) => applyNativeAnthropicEdits(event.payload, getContextManagementConfig()));
2259
+ };
2260
+ }
2261
+ //#endregion
2262
+ //#region src/extensions/current-time.ts
2263
+ const log$8 = logger.child({ module: "current-time-extension" });
2264
+ const PI_DATE_LINE = /^Current date:.*$/m;
2265
+ function formatCurrentTimeLine(now) {
2266
+ return `Current date: ${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")} (${new Intl.DateTimeFormat("en-US", {
2267
+ weekday: "long",
2268
+ timeZone: "UTC"
2269
+ }).format(now)} UTC)`;
2270
+ }
2271
+ const currentTimeExtension = (pi) => {
2272
+ pi.on("before_agent_start", (event) => {
2273
+ const line = formatCurrentTimeLine(/* @__PURE__ */ new Date());
2274
+ const base = event.systemPrompt;
2275
+ if (PI_DATE_LINE.test(base)) {
2276
+ log$8.info({ event: "pi_date_line_present" }, "pi base prompt carries its own 'Current date:' line again; replacing it in place (pi prompt format may have changed)");
2277
+ return { systemPrompt: base.replace(PI_DATE_LINE, line) };
2278
+ }
2279
+ return { systemPrompt: `${base}\n${line}` };
2280
+ });
2281
+ };
2282
+ //#endregion
2283
+ //#region src/memory.ts
2284
+ /**
2285
+ * In-harness memory index builder.
2286
+ *
2287
+ * The agent has an agent-level file-based memory at `<cwd>/.memory/`,
2288
+ * organized by directory:
2289
+ *
2290
+ * .memory/users/<id>-<name>/<topic>.md
2291
+ * .memory/projects/<project_slug>/<topic>.md
2292
+ * .memory/feedback/<topic>.md
2293
+ * .memory/reference/<topic>.md
2294
+ *
2295
+ * The path encodes type and subject (for `users/`, the subject is the
2296
+ * person's stable id with a readable name suffix); each `.md` file's
2297
+ * frontmatter only carries `name` and `description`.
2298
+ *
2299
+ * `buildMemoryIndex` walks `.memory/` by type directory, reads only the
2300
+ * frontmatter of each `.md` (open fd → read first ~4KB → close, in
2301
+ * parallel), and renders a markdown index grouped by type and (where
2302
+ * applicable) by subject. Files outside the four type directories are
2303
+ * ignored. Bodies are never read — the agent loads a specific memory's
2304
+ * body on demand via the `read` tool when the index entry says it's
2305
+ * relevant.
2306
+ *
2307
+ * Mtime cache keyed by cwd — within the lifetime of a sandbox the cwd
2308
+ * is fixed, so this is effectively a single-entry cache. Cache invalidates
2309
+ * when any `.md` in the tree is added/modified/deleted; turns where
2310
+ * memory didn't change reuse the cached string.
2311
+ *
2312
+ * Frontmatter is parsed as YAML (`yaml` package) and validated with a
2313
+ * zod schema — files that don't match the shape are dropped from the
2314
+ * index. The same schema can be reused at write time if we want to
2315
+ * validate before commit.
2316
+ */
2317
+ const FRONTMATTER_READ_BYTES = 4096;
2318
+ const FrontmatterSchema = z.object({
2319
+ name: z.string().min(1),
2320
+ description: z.string().min(1)
2321
+ }).passthrough();
2322
+ const MEMORY_DIRNAME = ".memory";
2323
+ const TYPE_DIRS = [
2324
+ "users",
2325
+ "projects",
2326
+ "feedback",
2327
+ "reference"
2328
+ ];
2329
+ const TYPES_WITH_SUBJECT = new Set(["users", "projects"]);
2330
+ const TYPE_LABELS = {
2331
+ users: "Users",
2332
+ projects: "Projects",
2333
+ feedback: "Feedback",
2334
+ reference: "Reference"
2335
+ };
2336
+ const cache = /* @__PURE__ */ new Map();
2337
+ /**
2338
+ * Returns:
2339
+ * - `null` if `.memory/` doesn't exist
2340
+ * - `""` if the dir exists but contains nothing in the requested scope
2341
+ * - rendered markdown body (no surrounding header — caller wraps)
2342
+ *
2343
+ * The mtime-keyed cache stores the raw walked entries (the cost is the FS
2344
+ * walk); filtering by scope is cheap and runs per call, so two turns with
2345
+ * different scopes on the same cwd render correctly from one cached walk.
2346
+ */
2347
+ async function buildMemoryIndex({ cwd, scope }) {
2348
+ const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
2349
+ const maxMtimeMs = await maxMtimeAcrossDir(memoryDirAbs);
2350
+ if (maxMtimeMs === null) {
2351
+ cache.delete(cwd);
2352
+ return null;
2353
+ }
2354
+ let cached = cache.get(cwd);
2355
+ if (!cached || cached.builtAtMs < maxMtimeMs) {
2356
+ cached = {
2357
+ entries: await collectEntries(memoryDirAbs, cwd),
2358
+ builtAtMs: Date.now()
2359
+ };
2360
+ cache.set(cwd, cached);
2361
+ }
2362
+ const visible = cached.entries.filter((entry) => scope.kind === "user" ? entry.type === "users" && entry.subject?.startsWith(scope.userId) === true : entry.type !== "users");
2363
+ return visible.length === 0 ? "" : renderIndex(visible);
2364
+ }
2365
+ async function maxMtimeAcrossDir(dir) {
2366
+ let dirStat;
2367
+ try {
2368
+ dirStat = await stat(dir);
2369
+ } catch {
2370
+ return null;
2371
+ }
2372
+ if (!dirStat.isDirectory()) return null;
2373
+ let max = dirStat.mtimeMs;
2374
+ const files = [];
2375
+ await walkMdFiles(dir, files);
2376
+ const fileStats = await Promise.all(files.map((f) => stat(f).catch(() => null)));
2377
+ for (const s of fileStats) if (s && s.mtimeMs > max) max = s.mtimeMs;
2378
+ return max;
2379
+ }
2380
+ async function walkMdFiles(dir, out) {
2381
+ let entries;
2382
+ try {
2383
+ entries = await readdir(dir, { withFileTypes: true });
2384
+ } catch {
2385
+ return;
2386
+ }
2387
+ await Promise.all(entries.map(async (entry) => {
2388
+ const full = join(dir, entry.name);
2389
+ if (entry.isDirectory()) await walkMdFiles(full, out);
2390
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
2391
+ }));
2392
+ }
2393
+ async function listMdFilesShallow(dir) {
2394
+ let entries;
2395
+ try {
2396
+ entries = await readdir(dir, { withFileTypes: true });
2397
+ } catch {
2398
+ return [];
2399
+ }
2400
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => join(dir, e.name));
2401
+ }
2402
+ async function listSubdirs(dir) {
2403
+ let entries;
2404
+ try {
2405
+ entries = await readdir(dir, { withFileTypes: true });
2406
+ } catch {
2407
+ return [];
2408
+ }
2409
+ return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
2410
+ }
2411
+ async function collectEntries(rootDirAbs, cwd) {
2412
+ const collected = [];
2413
+ await Promise.all(TYPE_DIRS.map(async (type) => {
2414
+ const typeDirAbs = join(rootDirAbs, type);
2415
+ if (TYPES_WITH_SUBJECT.has(type)) {
2416
+ const subjectDirs = await listSubdirs(typeDirAbs);
2417
+ await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
2418
+ const subject = basename(subjectDirAbs);
2419
+ const files = await listMdFilesShallow(subjectDirAbs);
2420
+ const parsed = await Promise.all(files.map(async (file) => {
2421
+ const fm = await readFrontmatterOnly(file);
2422
+ if (!fm?.name || !fm?.description) return null;
2423
+ return {
2424
+ name: fm.name,
2425
+ description: fm.description,
2426
+ type,
2427
+ subject,
2428
+ relPath: relative(cwd, file)
2429
+ };
2430
+ }));
2431
+ for (const e of parsed) if (e) collected.push(e);
2432
+ }));
2433
+ } else {
2434
+ const files = await listMdFilesShallow(typeDirAbs);
2435
+ const parsed = await Promise.all(files.map(async (file) => {
2436
+ const fm = await readFrontmatterOnly(file);
2437
+ if (!fm?.name || !fm?.description) return null;
2438
+ return {
2439
+ name: fm.name,
2440
+ description: fm.description,
2441
+ type,
2442
+ subject: null,
2443
+ relPath: relative(cwd, file)
2444
+ };
2445
+ }));
2446
+ for (const e of parsed) if (e) collected.push(e);
2447
+ }
2448
+ }));
2449
+ return collected;
2450
+ }
2451
+ async function readFrontmatterOnly(filePath) {
2452
+ let fh;
2453
+ try {
2454
+ fh = await open(filePath, "r");
2455
+ } catch {
2456
+ return null;
2457
+ }
2458
+ try {
2459
+ const buf = Buffer.alloc(FRONTMATTER_READ_BYTES);
2460
+ const { bytesRead } = await fh.read(buf, 0, FRONTMATTER_READ_BYTES, 0);
2461
+ return parseFrontmatter(buf.toString("utf-8", 0, bytesRead));
2462
+ } catch {
2463
+ return null;
2464
+ } finally {
2465
+ await fh.close().catch(() => {});
2466
+ }
2467
+ }
2468
+ function parseFrontmatter(text) {
2469
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2470
+ if (!match || match[1] === void 0) return null;
2471
+ let parsed;
2472
+ try {
2473
+ parsed = parse(match[1]);
2474
+ } catch {
2475
+ return null;
2476
+ }
2477
+ const result = FrontmatterSchema.safeParse(parsed);
2478
+ return result.success ? result.data : null;
2479
+ }
2480
+ function renderIndex(entries) {
2481
+ const byType = {
2482
+ users: [],
2483
+ projects: [],
2484
+ feedback: [],
2485
+ reference: []
2486
+ };
2487
+ for (const e of entries) byType[e.type].push(e);
2488
+ const sections = [];
2489
+ for (const type of TYPE_DIRS) {
2490
+ const items = byType[type];
2491
+ if (items.length === 0) continue;
2492
+ sections.push(`### ${TYPE_LABELS[type]}`);
2493
+ if (TYPES_WITH_SUBJECT.has(type)) {
2494
+ const bySubject = /* @__PURE__ */ new Map();
2495
+ for (const e of items) {
2496
+ const subject = e.subject ?? "(unknown)";
2497
+ const list = bySubject.get(subject) ?? [];
2498
+ list.push(e);
2499
+ bySubject.set(subject, list);
2500
+ }
2501
+ const subjects = [...bySubject.keys()].sort();
2502
+ for (const subject of subjects) {
2503
+ sections.push(`- **${subject}**`);
2504
+ for (const e of bySubject.get(subject) ?? []) sections.push(` - \`${e.relPath}\` — ${e.description}`);
2505
+ }
2506
+ } else for (const e of items) sections.push(`- \`${e.relPath}\` — ${e.description}`);
2507
+ sections.push("");
2508
+ }
2509
+ return sections.join("\n").trimEnd();
2510
+ }
2511
+ //#endregion
2512
+ //#region src/extensions/memory.ts
2513
+ const log$7 = logger.child({ module: "memory-extension" });
2514
+ /**
2515
+ * The standing instructions for the memory system. Always injected (even with
2516
+ * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
2517
+ * described by the platform memory extension, which is the only thing that can
2518
+ * scope it to a person — here we just point at it.
2519
+ */
2520
+ function memoryInstructions(cwd) {
2521
+ return `## Memory across conversations
2522
+
2523
+ 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.
2524
+
2525
+ 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.
2526
+
2527
+ 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.`;
2528
+ }
2529
+ function composeBlock$1({ cwd, index }) {
2530
+ const instructions = memoryInstructions(cwd);
2531
+ if (!index || index.length === 0) return instructions;
2532
+ return `${instructions}\n\n## Memory index\n\n${index}`;
2533
+ }
2534
+ const memoryExtension = (pi) => {
2535
+ let cachedBlock = null;
2536
+ pi.on("session_start", async (_event, ctx) => {
2537
+ try {
2538
+ const index = await buildMemoryIndex({
2539
+ cwd: ctx.cwd,
2540
+ scope: { kind: "shared" }
2541
+ });
2542
+ cachedBlock = composeBlock$1({
2543
+ cwd: ctx.cwd,
2544
+ index
2545
+ });
2546
+ } catch (err) {
2547
+ log$7.warn({
2548
+ err,
2549
+ event: "memory_index_failed"
2550
+ }, "memory index build failed; injecting instructions only");
2551
+ cachedBlock = memoryInstructions(ctx.cwd);
2552
+ }
2553
+ });
2554
+ pi.on("before_agent_start", (event) => {
2555
+ if (!cachedBlock) return void 0;
2556
+ return { systemPrompt: `${event.systemPrompt}\n\n${cachedBlock}` };
2557
+ });
2558
+ };
2559
+ //#endregion
2560
+ //#region src/extensions/platform-memory.ts
2561
+ const log$6 = logger.child({ module: "platform-memory-extension" });
2562
+ /**
2563
+ * Resolve the human on this turn via the API, keyed by the message id.
2564
+ * `/sandbox/channel-context` only returns a sender for a platform-known
2565
+ * account (a `userId`); accountless channel senders (Slack/email) come back as
2566
+ * no sender. Returns `null` outside a turn, on any failure, or when there's no
2567
+ * account — all of which withhold `users/` memory rather than scoping it to a
2568
+ * non-account identity. We use the typed `hc<SandboxAppType>` client, so the
2569
+ * response shape can't drift from the route.
2570
+ */
2571
+ async function resolveTurnUser(messageId) {
2572
+ const client = sandboxClient();
2573
+ if (!client) {
2574
+ log$6.debug({ event: "resolve_turn_user_no_api_url" }, "no API url in env; withholding user memory");
2575
+ return null;
2576
+ }
2577
+ try {
2578
+ const res = await client["channel-context"].$get({ query: { messageId } });
2579
+ if (!res.ok) {
2580
+ log$6.warn({
2581
+ event: "resolve_turn_user_failed",
2582
+ status: res.status
2583
+ }, "channel-context returned non-ok; withholding user memory");
2584
+ return null;
2585
+ }
2586
+ const { sender } = await res.json();
2587
+ if (!sender) return null;
2588
+ return {
2589
+ id: sender.userId,
2590
+ displayName: sender.displayName
2591
+ };
2592
+ } catch (err) {
2593
+ log$6.warn({
2594
+ err,
2595
+ event: "resolve_turn_user_failed"
2596
+ }, "failed to resolve current user; withholding user memory");
2597
+ return null;
2598
+ }
2599
+ }
2600
+ /** Filesystem-safe, readable suffix for the per-user memory directory. */
2601
+ function slugifyName(name) {
2602
+ if (!name) return "user";
2603
+ const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
2604
+ return slug.length > 0 ? slug : "user";
2605
+ }
2606
+ function composeBlock({ index, user }) {
2607
+ const instructions = `## Current user memory
2608
+
2609
+ 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.`;
2610
+ if (!index || index.length === 0) return instructions;
2611
+ return `${instructions}\n\n${index}`;
2612
+ }
2613
+ /**
2614
+ * Build the platform memory extension. `channelContext` is the per-turn ref
2615
+ * blob the worker injects (it carries the message id used to resolve the
2616
+ * user).
2617
+ */
2618
+ function createPlatformMemoryExtension({ channelContext }) {
2619
+ return (pi) => {
2620
+ let cachedBlock = null;
2621
+ pi.on("session_start", async (_event, ctx) => {
2622
+ try {
2623
+ const messageId = extractMessageId(channelContext);
2624
+ const user = messageId ? await resolveTurnUser(messageId) : null;
2625
+ if (!user) {
2626
+ cachedBlock = null;
2627
+ return;
2628
+ }
2629
+ cachedBlock = composeBlock({
2630
+ index: await buildMemoryIndex({
2631
+ cwd: ctx.cwd,
2632
+ scope: {
2633
+ kind: "user",
2634
+ userId: user.id
2635
+ }
2636
+ }),
2637
+ user
2638
+ });
2639
+ } catch (err) {
2640
+ log$6.warn({
2641
+ err,
2642
+ event: "user_memory_index_failed"
2643
+ }, "user memory index build failed; skipping injection");
2644
+ cachedBlock = null;
2645
+ }
2646
+ });
2647
+ pi.on("before_agent_start", (event) => {
2648
+ if (!cachedBlock) return void 0;
2649
+ return { systemPrompt: `${event.systemPrompt}\n\n${cachedBlock}` };
2650
+ });
2651
+ };
2652
+ }
2653
+ //#endregion
2654
+ //#region src/extensions/self-trace.ts
2655
+ const log$5 = logger.child({ module: "self-trace-extension" });
2656
+ /**
2657
+ * Reports the agent's own execution as OpenTelemetry spans:
2658
+ * agent.session → agent.run → agent.turn.N → tool.NAME, with token/cost
2659
+ * usage on the run span and exception events on failed tool spans.
2660
+ *
2661
+ * Reporting only — querying traces back is the platform CLI's job
2662
+ * (`platform trace list` / `platform trace query`), scoped server-side
2663
+ * to the calling agent's id.
2664
+ */
2665
+ const selfTraceExtension = (pi) => {
2666
+ let sessionSpan = null;
2667
+ let sessionCtx = null;
2668
+ let runSpan = null;
2669
+ let runCtx = null;
2670
+ let turnSpan = null;
2671
+ let turnCtx = null;
2672
+ const toolSpans = /* @__PURE__ */ new Map();
2673
+ pi.on("session_start", async (_event, ctx) => {
2674
+ const tracer = getTracer();
2675
+ const remoteCtx = extractRemoteContext();
2676
+ const modelId = ctx.model?.id ?? "unknown";
2677
+ sessionSpan = tracer.startSpan("agent.session", { attributes: { "agent.model": modelId } }, remoteCtx);
2678
+ sessionCtx = trace.setSpan(remoteCtx, sessionSpan);
2679
+ const sc = sessionSpan.spanContext();
2680
+ log$5.info({
2681
+ event: "self_trace_session_start",
2682
+ trace_id: sc.traceId,
2683
+ span_id: sc.spanId,
2684
+ model: modelId
2685
+ }, "self-trace session span started");
2686
+ });
2687
+ pi.on("agent_start", () => {
2688
+ if (!sessionCtx) return;
2689
+ runSpan = getTracer().startSpan("agent.run", {}, sessionCtx);
2690
+ runCtx = trace.setSpan(sessionCtx, runSpan);
2691
+ });
2692
+ pi.on("turn_start", (event) => {
2693
+ const parentCtx = runCtx ?? sessionCtx;
2694
+ if (!parentCtx) return;
2695
+ const tracer = getTracer();
2696
+ const turnIndex = event.turnIndex ?? 0;
2697
+ turnSpan = tracer.startSpan(`agent.turn.${turnIndex}`, { attributes: { "turn.index": turnIndex } }, parentCtx);
2698
+ turnCtx = trace.setSpan(parentCtx, turnSpan);
2699
+ });
2700
+ pi.on("tool_execution_start", (event) => {
2701
+ const parentCtx = turnCtx ?? runCtx ?? sessionCtx;
2702
+ if (!parentCtx) return;
2703
+ const { toolCallId, toolName } = event;
2704
+ const span = getTracer().startSpan(`tool.${toolName}`, { attributes: {
2705
+ "tool.name": toolName,
2706
+ "tool.call_id": toolCallId
2707
+ } }, parentCtx);
2708
+ toolSpans.set(toolCallId, span);
2709
+ });
2710
+ pi.on("tool_execution_end", (event) => {
2711
+ const { toolCallId, isError, result } = event;
2712
+ const span = toolSpans.get(toolCallId);
2713
+ if (!span) return;
2714
+ span.setAttribute("tool.is_error", isError);
2715
+ if (isError) {
2716
+ span.setStatus({ code: SpanStatusCode.ERROR });
2717
+ const errorText = typeof result === "string" ? result : Array.isArray(result?.content) ? result.content.filter((p) => p?.type === "text").map((p) => p.text ?? "").join("") : "tool execution failed";
2718
+ span.recordException(new Error(errorText));
2719
+ }
2720
+ span.end();
2721
+ toolSpans.delete(toolCallId);
2722
+ });
2723
+ pi.on("turn_end", () => {
2724
+ if (turnSpan) {
2725
+ turnSpan.end();
2726
+ turnSpan = null;
2727
+ turnCtx = null;
2728
+ }
2729
+ });
2730
+ pi.on("agent_end", (event) => {
2731
+ const ev = event;
2732
+ if (ev.messages && runSpan) {
2733
+ let totalInput = 0;
2734
+ let totalOutput = 0;
2735
+ let totalCacheRead = 0;
2736
+ let totalCacheWrite = 0;
2737
+ let totalTokens = 0;
2738
+ let totalCost = 0;
2739
+ for (const m of ev.messages) if (m?.role === "assistant" && m.usage) {
2740
+ totalInput += m.usage.input ?? 0;
2741
+ totalOutput += m.usage.output ?? 0;
2742
+ totalCacheRead += m.usage.cacheRead ?? 0;
2743
+ totalCacheWrite += m.usage.cacheWrite ?? 0;
2744
+ totalTokens += m.usage.totalTokens ?? 0;
2745
+ totalCost += m.usage.cost?.total ?? 0;
2746
+ }
2747
+ runSpan.setAttributes({
2748
+ "llm.usage.input_tokens": totalInput,
2749
+ "llm.usage.output_tokens": totalOutput,
2750
+ "llm.usage.cache_read_tokens": totalCacheRead,
2751
+ "llm.usage.cache_write_tokens": totalCacheWrite,
2752
+ "llm.usage.total_tokens": totalTokens,
2753
+ "llm.usage.cost": totalCost
2754
+ });
2755
+ }
2756
+ for (const [id, span] of toolSpans) {
2757
+ span.end();
2758
+ toolSpans.delete(id);
2759
+ }
2760
+ if (turnSpan) {
2761
+ turnSpan.end();
2762
+ turnSpan = null;
2763
+ turnCtx = null;
2764
+ }
2765
+ if (runSpan) {
2766
+ runSpan.end();
2767
+ runSpan = null;
2768
+ runCtx = null;
2769
+ }
2770
+ if (sessionSpan) {
2771
+ sessionSpan.end();
2772
+ sessionSpan = null;
2773
+ sessionCtx = null;
2774
+ }
2775
+ });
2776
+ };
2777
+ //#endregion
2778
+ //#region src/extensions/soul.ts
2779
+ /**
2780
+ * Soul adapter as a pi extension.
2781
+ *
2782
+ * On `session_start`, reads `soul.md` from the agent's repo and caches it.
2783
+ * On `before_agent_start`, appends a "Persona / Standing instructions"
2784
+ * section to the system prompt using the cached content. The block is
2785
+ * always emitted (even when `soul.md` is absent) so the agent learns the
2786
+ * affordance — `soul.md` is editable, picked up on the next message, and
2787
+ * is the place to redefine itself.
2788
+ *
2789
+ * Lives in the harness package — soul.md is content from the agent's
2790
+ * own git repo, not from the platform — so its handling stays here.
2791
+ */
2792
+ const log$4 = logger.child({ module: "soul-extension" });
2793
+ async function readSoul(cwd) {
2794
+ try {
2795
+ return (await readFile(join(cwd, "soul.md"), "utf8")).trim() || null;
2796
+ } catch (err) {
2797
+ if (err?.code === "ENOENT") return null;
2798
+ log$4.warn({
2799
+ err,
2800
+ event: "soul_read_failed"
2801
+ }, "soul.md read failed");
2802
+ return null;
2803
+ }
2804
+ }
2805
+ function soulSection(cwd, soul) {
2806
+ return `# Persona / Standing instructions
2807
+
2808
+ \`${cwd}/soul.md\` is your durable self — the one place that says who you are, how you should behave, and what you're for: your persona, your principles, your standing behavioral rules, your recurring style preferences, and the capabilities that make up your purpose. It is the only identity that carries across conversations. The tool, skill, and integration lists in this prompt tell you what's *available* in this session; \`soul.md\` is what you *are*.
2809
+
2810
+ **\`soul.md\` is where behavior lives.** Any standing instruction about how you should act — a rule a user wants you to follow going forward, a tone or format preference, a workflow convention, a "from now on, always/never …" — belongs here, not in \`.memory/\`. Memory records *what happened* (facts, events, findings); soul defines *how you behave*. When a user gives you a durable behavioral rule, write it to \`soul.md\`. If you find behavioral rules that ended up in \`.memory/\`, treat that as misfiled and move them here.
2811
+
2812
+ Keep it current. When you gain a durable new capability — a tool you build, a skill or integration you set up, a service you connect, a secret or auth credential you wire in — or a user hands you a lasting behavioral rule, record it in \`soul.md\` so a future conversation knows it's part of you rather than rediscovering it from scratch. Do this the moment you gain the capability, and for a credential that means the moment it verifies with a real call, not after a human points out that you forgot. Connecting a capability is itself a durable change worth recording, not merely a step toward the task in front of you. Edit \`soul.md\` (then \`git add soul.md && git commit && git push\`) to redefine yourself; picked up on the next message.
2813
+
2814
+ ${soul ? soul : "_(empty — write to `soul.md` to define your persona)_"}`;
2815
+ }
2816
+ const soulExtension = (pi) => {
2817
+ let cachedSection = null;
2818
+ pi.on("session_start", async (_event, ctx) => {
2819
+ const soul = await readSoul(ctx.cwd);
2820
+ cachedSection = soulSection(ctx.cwd, soul);
2821
+ });
2822
+ pi.on("before_agent_start", (event, ctx) => {
2823
+ const section = cachedSection ?? soulSection(ctx.cwd, null);
2824
+ const base = event.systemPrompt;
2825
+ return { systemPrompt: base.length > 0 ? `${base}\n\n${section}` : section };
2826
+ });
2827
+ };
2828
+ //#endregion
2829
+ //#region src/extensions/subagent/index.ts
2830
+ const log$3 = logger.child({ module: "subagent-ext" });
2831
+ const MAX_TASKS = 8;
2832
+ const TaskItem = Type.Object({
2833
+ task: Type.String({ description: "The task to delegate to a subagent run." }),
2834
+ title: Type.String({
2835
+ description: "A SHORT name for this task — 3-6 words, sentence case, no trailing period. This is what the person in the chat sees as the row for this subagent, so name the work, do not restate the prompt. Good: \"Audit the billing gate\", \"Compare competitor pricing\", \"Draft the migration\". Bad: \"You are looking at apps/anyone/web and should check every component…\".",
2836
+ maxLength: 120
2837
+ }),
2838
+ 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." })),
2839
+ 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." })),
2840
+ timeoutMinutes: Type.Optional(Type.Integer({
2841
+ 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.",
2842
+ minimum: 1,
2843
+ maximum: 360
2844
+ }))
2845
+ });
2846
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 6e4;
2847
+ const SubagentParams = Type.Object({ tasks: Type.Array(TaskItem, {
2848
+ 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.",
2849
+ minItems: 1,
2850
+ maxItems: MAX_TASKS
2851
+ }) });
2852
+ const SUBAGENT_TOOL_NAME = "subagent";
2853
+ function buildTool(messageId) {
2854
+ return {
2855
+ name: SUBAGENT_TOOL_NAME,
2856
+ label: "Subagent",
2857
+ description: [
2858
+ "Delegate one or more tasks to subagent runs — fresh isolated copies of yourself, each with its own context window, linked to this conversation.",
2859
+ "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.",
2860
+ "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.",
2861
+ "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.",
2862
+ "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.",
2863
+ "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."
2864
+ ].join(" "),
2865
+ promptSnippet: "subagent — delegate tasks to isolated subagent runs; each rewakes you with its result when done",
2866
+ parameters: SubagentParams,
2867
+ async execute(_toolCallId, params) {
2868
+ const { tasks } = params;
2869
+ if (!messageId) return {
2870
+ content: [{
2871
+ type: "text",
2872
+ text: "Subagent delegation is unavailable in this context (no originating conversation to link the runs to)."
2873
+ }],
2874
+ details: {},
2875
+ isError: true
2876
+ };
2877
+ const spawnTasks = tasks.map((t) => ({
2878
+ task: t.task,
2879
+ title: t.title ?? null,
2880
+ persona: t.persona ?? null,
2881
+ model: t.model ?? null,
2882
+ timeoutMs: t.timeoutMinutes != null ? t.timeoutMinutes * 6e4 : DEFAULT_SUBAGENT_TIMEOUT_MS
2883
+ }));
2884
+ try {
2885
+ const spawned = await postSubagentSpawn({
2886
+ messageId,
2887
+ tasks: spawnTasks
2888
+ });
2889
+ const { taskIds } = spawned;
2890
+ log$3.info({
2891
+ event: "subagent_spawned",
2892
+ count: taskIds.length
2893
+ }, "subagent tasks queued");
2894
+ const convByTask = new Map(spawned.tasks.map((t) => [t.taskId, t.conversationId]));
2895
+ const lines = taskIds.map((id, i) => {
2896
+ const label = spawnTasks[i]?.title ?? spawnTasks[i]?.task ?? "";
2897
+ const conv = convByTask.get(id);
2898
+ return `- ${id}: ${label}${conv ? ` — conversation ${conv}` : ""}`;
2899
+ }).join("\n");
2900
+ 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." : "";
2901
+ return {
2902
+ content: [{
2903
+ type: "text",
2904
+ 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}`
2905
+ }],
2906
+ details: {
2907
+ taskIds,
2908
+ tasks: spawned.tasks
2909
+ }
2910
+ };
2911
+ } catch (err) {
2912
+ const message = err instanceof Error ? err.message : String(err);
2913
+ log$3.warn({
2914
+ err,
2915
+ event: "subagent_spawn_failed"
2916
+ }, "subagent spawn failed");
2917
+ return {
2918
+ content: [{
2919
+ type: "text",
2920
+ text: `Failed to queue subagent runs: ${message}`
2921
+ }],
2922
+ details: {},
2923
+ isError: true
2924
+ };
2925
+ }
2926
+ }
2927
+ };
2928
+ }
2929
+ /**
2930
+ * The factory takes the session's channel context to resolve the originating
2931
+ * messageId — the api links each spawned run to the conversation that message
2932
+ * belongs to and rewakes it on completion (nothing about the parent is piped
2933
+ * from the sandbox beyond that id). The tool is registered unconditionally at
2934
+ * session_start.
2935
+ */
2936
+ function createSubagentExtension({ channelContext }) {
2937
+ return (pi) => {
2938
+ const messageId = extractMessageId(channelContext);
2939
+ let registered = false;
2940
+ const registerOnce = () => {
2941
+ if (registered) return;
2942
+ registered = true;
2943
+ pi.registerTool(buildTool(messageId));
2944
+ log$3.info({ event: "subagent_enabled" }, "subagent tool registered");
2945
+ };
2946
+ pi.on("session_start", () => {
2947
+ registerOnce();
2948
+ });
2949
+ };
2950
+ }
2951
+ //#endregion
2952
+ //#region src/extensions/resource-pressure-warning.ts
2953
+ /**
2954
+ * Mid-run resource-pressure warning to the agent.
2955
+ *
2956
+ * The sandbox already detects pressure — the boot scripts cap the
2957
+ * user-workload cgroup (memory.high/memory.max) and watchers log warn/crit
2958
+ * edges for memory and disk — but nothing told the *agent*, so a turn burned
2959
+ * straight to the OOM kill (or a full disk) and only learned about it from
2960
+ * the post-mortem notice. This extension closes that gap in-process: while a
2961
+ * turn is active it polls the agent cgroup and the root filesystem and, the
2962
+ * first time usage crosses a warn threshold, folds a system notification into
2963
+ * the open turn so the agent can checkpoint, shed work (constrain
2964
+ * parallelism, kill a background hog, clean scratch space), or request a
2965
+ * bigger tier BEFORE the kill.
2966
+ *
2967
+ * The notification is triggered by the two conditions that actually kill
2968
+ * work — memory near the cgroup hard cap, disk near full — and reports a
2969
+ * snapshot of all the relevant stats (memory, CPU utilization, disk) so the
2970
+ * agent can tell which resource is the problem and how much headroom the
2971
+ * others have.
2972
+ *
2973
+ * Edge-triggered, once per trigger per turn: the fired flags reset on
2974
+ * agent_start, so a turn that rides a threshold gets one warning per
2975
+ * resource, not a stream. Polling only runs while the agent is active — an
2976
+ * idle sandbox's resource usage is not the agent's problem and there is no
2977
+ * open turn to deliver into anyway.
2978
+ *
2979
+ * Best-effort throughout: any read failure (cgroup absent, controller not
2980
+ * delegated, non-cgroup-v2 host, df missing) reads as "no signal" for that
2981
+ * stat and the extension warns on what it can see — it must never break a
2982
+ * turn over an observability feature.
2983
+ */
2984
+ const execFileAsync = promisify(execFile);
2985
+ const log$2 = logger.child({ module: "resource-pressure-warning" });
2986
+ const POLL_INTERVAL_MS = 1e4;
2987
+ function envOverride(name) {
2988
+ for (const prefix of ["SKYDIVE_", "ANYONE_"]) {
2989
+ const value = process.env[`${prefix}${name}`];
2990
+ if (value != null && value !== "") return value;
2991
+ }
2992
+ return null;
2993
+ }
2994
+ function cgroupDir() {
2995
+ return envOverride("AGENT_CGROUP") ?? "/sys/fs/cgroup/agent";
2996
+ }
2997
+ function diskRoot() {
2998
+ return envOverride("DISK_ROOT") ?? "/";
2999
+ }
3000
+ /**
3001
+ * Read a cgroup v2 scalar file. Returns a number, or null for "max"
3002
+ * (uncapped), an empty/absent file, or any read/parse error — an uncapped or
3003
+ * unreadable limit means there is nothing meaningful to warn against.
3004
+ */
3005
+ async function readScalar(file) {
3006
+ try {
3007
+ const raw = (await readFile(`${cgroupDir()}/${file}`, "utf8")).trim();
3008
+ if (raw === "" || raw === "max") return null;
3009
+ const n = Number(raw);
3010
+ return Number.isFinite(n) ? n : null;
3011
+ } catch (_error) {
3012
+ return null;
3013
+ }
3014
+ }
3015
+ /**
3016
+ * Read a cgroup v2 "flat keyed" file (one `key value` pair per line, e.g.
3017
+ * cpu.stat) and return the counter for `key`, or null when absent.
3018
+ */
3019
+ async function readKeyedCounter(file, key) {
3020
+ try {
3021
+ const raw = await readFile(`${cgroupDir()}/${file}`, "utf8");
3022
+ for (const line of raw.split("\n")) {
3023
+ const [k, v] = line.trim().split(/\s+/);
3024
+ if (k === key) {
3025
+ const n = Number(v);
3026
+ return Number.isFinite(n) ? n : null;
3027
+ }
3028
+ }
3029
+ return null;
3030
+ } catch (_error) {
3031
+ return null;
3032
+ }
3033
+ }
3034
+ /**
3035
+ * Live memory usage as an integer percent of the hard cap, or null when
3036
+ * either side is unreadable/uncapped. Exported for tests.
3037
+ */
3038
+ async function readMemUsePct() {
3039
+ const [current, max] = await Promise.all([readScalar("memory.current"), readScalar("memory.max")]);
3040
+ if (current === null || max === null || max <= 0) return null;
3041
+ return {
3042
+ pct: Math.floor(current / max * 100),
3043
+ currentBytes: current,
3044
+ maxBytes: max
3045
+ };
3046
+ }
3047
+ /**
3048
+ * Root filesystem used% (df -P Capacity column), or null on any failure.
3049
+ * Exported for tests.
3050
+ */
3051
+ async function readDiskUsePct() {
3052
+ try {
3053
+ const { stdout } = await execFileAsync("df", ["-P", diskRoot()]);
3054
+ const dataRow = stdout.trim().split("\n")[1];
3055
+ if (dataRow == null) return null;
3056
+ const capacity = dataRow.trim().split(/\s+/)[4];
3057
+ if (capacity == null) return null;
3058
+ const pct = Number(capacity.replace("%", ""));
3059
+ return Number.isFinite(pct) ? pct : null;
3060
+ } catch (_error) {
3061
+ return null;
3062
+ }
3063
+ }
3064
+ /**
3065
+ * CPU utilization sampler. cgroup v2 exposes cumulative CPU time
3066
+ * (cpu.stat usage_usec); utilization is the delta between two samples over
3067
+ * the wall time between them, normalized by core count. The first call after
3068
+ * construction has no previous sample and returns null.
3069
+ */
3070
+ function createCpuSampler() {
3071
+ let prevUsageUsec = null;
3072
+ let prevAtMs = null;
3073
+ return async () => {
3074
+ const usage = await readKeyedCounter("cpu.stat", "usage_usec");
3075
+ const now = Date.now();
3076
+ const prev = prevUsageUsec;
3077
+ const prevAt = prevAtMs;
3078
+ prevUsageUsec = usage;
3079
+ prevAtMs = now;
3080
+ if (usage === null || prev === null || prevAt === null) return null;
3081
+ const wallUsec = (now - prevAt) * 1e3;
3082
+ if (wallUsec <= 0) return null;
3083
+ const cores = availableParallelism();
3084
+ const pct = Math.round((usage - prev) / (wallUsec * cores) * 100);
3085
+ return Math.max(0, Math.min(100, pct));
3086
+ };
3087
+ }
3088
+ function fmtMb(bytes) {
3089
+ return Math.round(bytes / 1024 / 1024);
3090
+ }
3091
+ /** The model-facing warning text. Exported for tests. */
3092
+ function resourcePressureWarningText(trigger, { mem, cpuPct, diskPct }) {
3093
+ const stats = [];
3094
+ if (mem) stats.push(`memory ${mem.pct}% of cap (${fmtMb(mem.currentBytes)}/${fmtMb(mem.maxBytes)} MB)`);
3095
+ if (cpuPct !== null) stats.push(`CPU ${cpuPct}%`);
3096
+ if (diskPct !== null) stats.push(`disk ${diskPct}% full`);
3097
+ 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.`;
3098
+ 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.";
3099
+ 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>`;
3100
+ }
3101
+ const resourcePressureWarningExtension = (pi) => {
3102
+ let agentActive = false;
3103
+ let warnedMemThisTurn = false;
3104
+ let warnedDiskThisTurn = false;
3105
+ let timer = null;
3106
+ const sampleCpu = createCpuSampler();
3107
+ async function checkOnce() {
3108
+ if (!agentActive || warnedMemThisTurn && warnedDiskThisTurn) return;
3109
+ const [mem, cpuPct, diskPct] = await Promise.all([
3110
+ readMemUsePct(),
3111
+ sampleCpu(),
3112
+ readDiskUsePct()
3113
+ ]);
3114
+ let trigger = null;
3115
+ if (!warnedMemThisTurn && mem !== null && mem.pct >= 80) {
3116
+ trigger = "memory";
3117
+ warnedMemThisTurn = true;
3118
+ } else if (!warnedDiskThisTurn && diskPct !== null && diskPct >= 80) {
3119
+ trigger = "disk";
3120
+ warnedDiskThisTurn = true;
3121
+ }
3122
+ if (trigger === null) return;
3123
+ log$2.warn({
3124
+ trigger,
3125
+ mem,
3126
+ cpuPct,
3127
+ diskPct
3128
+ }, "resource pressure warning delivered to agent");
3129
+ await pi.sendMessage({
3130
+ customType: "anyone-resource-pressure-warning",
3131
+ content: resourcePressureWarningText(trigger, {
3132
+ mem,
3133
+ cpuPct,
3134
+ diskPct
3135
+ }),
3136
+ display: false
3137
+ }, {
3138
+ triggerTurn: true,
3139
+ deliverAs: "followUp"
3140
+ });
3141
+ }
3142
+ pi.on("agent_start", async () => {
3143
+ agentActive = true;
3144
+ warnedMemThisTurn = false;
3145
+ warnedDiskThisTurn = false;
3146
+ if (!timer) {
3147
+ timer = setInterval(() => {
3148
+ checkOnce().catch((err) => {
3149
+ log$2.error({ err }, "resource pressure check failed");
3150
+ });
3151
+ }, POLL_INTERVAL_MS);
3152
+ timer.unref?.();
3153
+ }
3154
+ });
3155
+ pi.on("agent_end", async () => {
3156
+ agentActive = false;
3157
+ if (timer) {
3158
+ clearInterval(timer);
3159
+ timer = null;
3160
+ }
3161
+ });
3162
+ };
3163
+ //#endregion
3164
+ //#region src/extensions/tool-call-env.ts
3165
+ const TOOL_CALL_ID_VAR = "TOOL_CALL_ID";
3166
+ function shellQuoteValue(value) {
3167
+ return quote([value]);
3168
+ }
3169
+ function withToolCallId({ command, toolCallId }) {
3170
+ return `export ${TOOL_CALL_ID_VAR}=${shellQuoteValue(toolCallId)}; ${command}`;
3171
+ }
3172
+ const PLATFORM_EXPORT = new RegExp(`^\\s*export\\s+(?:${TOOL_CALL_ID_VAR}|ANYONE_\\w+|SKYDIVE_\\w+)=(?:"(?:\\\\.|[^"])*"|'(?:'\\\\''|[^'])*'|[^;\\s]*)\\s*;\\s*`);
3173
+ function stripPlatformExportsForDisplay(command) {
3174
+ let c = command;
3175
+ let m;
3176
+ while (m = c.match(PLATFORM_EXPORT)) c = c.slice(m[0].length);
3177
+ return c;
3178
+ }
3179
+ const toolCallEnvExtension = (pi) => {
3180
+ pi.on("tool_call", async (event) => {
3181
+ if (event.toolName !== "bash") return;
3182
+ if (typeof event.input.command !== "string") return;
3183
+ event.input.command = withToolCallId({
3184
+ command: event.input.command,
3185
+ toolCallId: event.toolCallId
3186
+ });
3187
+ });
3188
+ };
3189
+ //#endregion
3190
+ //#region src/extensions/tool-call-summary.ts
3191
+ const log$1 = logger.child({ module: "tool-call-summary-extension" });
3192
+ /**
3193
+ * The injected parameter name: a namespaced sentinel, so it can never collide
3194
+ * with a real tool argument and is unmistakable in transcripts and logs. The
3195
+ * frontend renderer (ANY-2723) duplicates this literal — keep the two in sync.
3196
+ */
3197
+ const TOOL_CALL_SUMMARY_FIELD = "__skydive_summary__";
3198
+ /** JSON Schema fragment for the injected parameter. */
3199
+ const SUMMARY_PROPERTY = {
3200
+ type: "string",
3201
+ 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\"."
3202
+ };
3203
+ const jsonSchemaObjectSchema = z.object({
3204
+ type: z.unknown().optional(),
3205
+ properties: z.record(z.string(), z.unknown()).optional(),
3206
+ required: z.array(z.string()).optional(),
3207
+ additionalProperties: z.unknown().optional()
3208
+ }).passthrough();
3209
+ const toolEntrySchema = z.object({
3210
+ name: z.string().optional(),
3211
+ input_schema: jsonSchemaObjectSchema.optional(),
3212
+ parameters: jsonSchemaObjectSchema.optional(),
3213
+ function: z.object({
3214
+ name: z.string().optional(),
3215
+ parameters: jsonSchemaObjectSchema.optional()
3216
+ }).passthrough().optional()
3217
+ }).passthrough();
3218
+ const payloadWithToolsSchema = z.object({ tools: z.array(z.unknown()) }).passthrough();
3219
+ /**
3220
+ * Add the summary property to one JSON Schema object. Returns the augmented
3221
+ * copy, or `null` when the tool should be left untouched: a strict schema
3222
+ * (`additionalProperties: false`) whose validation would reject the extra
3223
+ * field, or one that already declares a `__skydive_summary__` property of its own.
3224
+ */
3225
+ function augmentSchema(schema) {
3226
+ if (schema.additionalProperties === false) return null;
3227
+ const properties = schema.properties ?? {};
3228
+ if ("__skydive_summary__" in properties) return null;
3229
+ const required = schema.required ?? [];
3230
+ return {
3231
+ ...schema,
3232
+ type: schema.type ?? "object",
3233
+ properties: {
3234
+ [TOOL_CALL_SUMMARY_FIELD]: SUMMARY_PROPERTY,
3235
+ ...properties
3236
+ },
3237
+ required: required.includes("__skydive_summary__") ? required : [...required, TOOL_CALL_SUMMARY_FIELD]
3238
+ };
3239
+ }
3240
+ /**
3241
+ * Augment a single tool entry, dispatching on which provider shape it is.
3242
+ * Returns the (possibly rebuilt) entry and whether anything changed. Skipped
3243
+ * tools — wrong shape, strict, or name in `strictToolNames` — return unchanged.
3244
+ */
3245
+ function augmentToolEntry(entry, strictToolNames) {
3246
+ const parsed = toolEntrySchema.safeParse(entry);
3247
+ if (!parsed.success) return {
3248
+ entry,
3249
+ changed: false
3250
+ };
3251
+ const tool = parsed.data;
3252
+ const name = tool.name ?? tool.function?.name ?? null;
3253
+ if (name !== null && strictToolNames.has(name)) return {
3254
+ entry,
3255
+ changed: false
3256
+ };
3257
+ if (tool.input_schema) {
3258
+ const augmented = augmentSchema(tool.input_schema);
3259
+ if (!augmented) return {
3260
+ entry,
3261
+ changed: false
3262
+ };
3263
+ return {
3264
+ entry: {
3265
+ ...tool,
3266
+ input_schema: augmented
3267
+ },
3268
+ changed: true
3269
+ };
3270
+ }
3271
+ if (tool.parameters) {
3272
+ const augmented = augmentSchema(tool.parameters);
3273
+ if (!augmented) return {
3274
+ entry,
3275
+ changed: false
3276
+ };
3277
+ return {
3278
+ entry: {
3279
+ ...tool,
3280
+ parameters: augmented
3281
+ },
3282
+ changed: true
3283
+ };
3284
+ }
3285
+ if (tool.function?.parameters) {
3286
+ const augmented = augmentSchema(tool.function.parameters);
3287
+ if (!augmented) return {
3288
+ entry,
3289
+ changed: false
3290
+ };
3291
+ return {
3292
+ entry: {
3293
+ ...tool,
3294
+ function: {
3295
+ ...tool.function,
3296
+ parameters: augmented
3297
+ }
3298
+ },
3299
+ changed: true
3300
+ };
3301
+ }
3302
+ return {
3303
+ entry,
3304
+ changed: false
3305
+ };
3306
+ }
3307
+ /**
3308
+ * Inject the summary field into every eligible tool in a provider payload.
3309
+ * Returns a new payload when at least one tool was augmented, or `undefined`
3310
+ * to signal "no change" (which keeps the original payload, per the
3311
+ * `before_provider_request` contract).
3312
+ *
3313
+ * @param payload The outgoing provider payload (shape varies by provider).
3314
+ * @param strictToolNames Names of tools whose registered schema is strict and
3315
+ * must be skipped to avoid validation errors.
3316
+ */
3317
+ function injectToolCallSummary(payload, strictToolNames) {
3318
+ const parsed = payloadWithToolsSchema.safeParse(payload);
3319
+ if (!parsed.success || parsed.data.tools.length === 0) return void 0;
3320
+ let changed = false;
3321
+ const tools = parsed.data.tools.map((entry) => {
3322
+ const result = augmentToolEntry(entry, strictToolNames);
3323
+ if (result.changed) changed = true;
3324
+ return result.entry;
3325
+ });
3326
+ if (!changed) return void 0;
3327
+ return {
3328
+ ...parsed.data,
3329
+ tools
3330
+ };
3331
+ }
3332
+ /**
3333
+ * Names of registered tools whose schema sets `additionalProperties: false`.
3334
+ * Pi validates the model's tool args against this registered schema, so the
3335
+ * injected field would make a strict tool's call fail validation — skip them.
3336
+ */
3337
+ function getStrictToolNames(pi) {
3338
+ const names = /* @__PURE__ */ new Set();
3339
+ for (const tool of pi.getAllTools()) {
3340
+ const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
3341
+ if (parsed.success && parsed.data.additionalProperties === false) names.add(tool.name);
3342
+ }
3343
+ return names;
3344
+ }
3345
+ function toolDeclaresSummaryParam(pi, toolName) {
3346
+ const tool = pi.getAllTools().find((candidate) => candidate.name === toolName);
3347
+ if (!tool) return false;
3348
+ const parsed = jsonSchemaObjectSchema.safeParse(tool.parameters);
3349
+ return parsed.success && parsed.data.properties != null && "__skydive_summary__" in parsed.data.properties;
3350
+ }
3351
+ /**
3352
+ * Remove the injected summary from a tool's execution input. No-op when the
3353
+ * field is absent, or when the tool genuinely declares a `__skydive_summary__`
3354
+ * parameter of its own (which we never inject into, so its value is real).
3355
+ * Mutates `input` in place, matching the `tool_call` contract.
3356
+ *
3357
+ * Fails open: this runs on the critical path of tool execution, and the
3358
+ * `getAllTools()` lookup can throw. On any error we leave `input` untouched
3359
+ * (the sentinel may pass through to the tool, but a bug here can never break
3360
+ * tool execution).
3361
+ */
3362
+ function stripInjectedSummary(pi, toolName, input) {
3363
+ try {
3364
+ if (!("__skydive_summary__" in input)) return;
3365
+ if (toolDeclaresSummaryParam(pi, toolName)) return;
3366
+ delete input[TOOL_CALL_SUMMARY_FIELD];
3367
+ } catch (err) {
3368
+ log$1.error({
3369
+ err,
3370
+ event: "tool_call_summary_strip_failed",
3371
+ toolName
3372
+ }, "tool_call_summary strip failed; leaving tool input untouched");
3373
+ }
3374
+ }
3375
+ /**
3376
+ * Compute the rewritten payload for a `before_provider_request` event, failing
3377
+ * open: on any error the original payload is left untouched so a bug here can
3378
+ * never break an LLM call.
3379
+ */
3380
+ function buildInjectedPayload(pi, payload) {
3381
+ try {
3382
+ return injectToolCallSummary(payload, getStrictToolNames(pi));
3383
+ } catch (err) {
3384
+ log$1.error({
3385
+ err,
3386
+ event: "tool_call_summary_injection_failed"
3387
+ }, "tool_call_summary injection failed; passing payload through unchanged");
3388
+ return;
3389
+ }
3390
+ }
3391
+ const toolCallSummaryExtension = (pi) => {
3392
+ pi.on("before_provider_request", (event) => buildInjectedPayload(pi, event.payload));
3393
+ pi.on("tool_call", (event) => {
3394
+ stripInjectedSummary(pi, event.toolName, event.input);
3395
+ });
3396
+ };
3397
+ //#endregion
3398
+ //#region src/extensions/background-tasks.ts
3399
+ /**
3400
+ * Background bash tasks as a pi extension.
3401
+ *
3402
+ * Gives the agent Claude Code-style background execution so a long-running
3403
+ * command never blocks the turn loop (a blocked turn means steers queue and
3404
+ * the user sees silence — the 2026-06-11 incident shape):
3405
+ *
3406
+ * - `bg_run` launches the command in the background, streaming output to a log
3407
+ * file, and returns immediately with a task id.
3408
+ * - `bg_status` / `bg_logs` / `bg_kill` inspect (with a stall hint), tail, and
3409
+ * stop a task.
3410
+ * - On completion the agent is woken: mid-run via
3411
+ * `pi.sendMessage(..., { deliverAs: 'followUp', triggerTurn: true })`; if no
3412
+ * agent loop is active (the run already ended), the extension POSTs the result
3413
+ * to the api (`/sandbox/bg-task-done`), which resolves the conversation from
3414
+ * the task's origin messageId and spawns a fresh run carrying it (the same
3415
+ * system-control-run path auth-fulfill / MCP-OAuth use to wake an idle agent —
3416
+ * a new run, not a steer). A lost POST falls back to injecting at the next
3417
+ * session_start for that conversation.
3418
+ * - A process-wide watchdog keeps the sandbox alive while any task runs
3419
+ * (capped) and kills tasks whose log exceeds the size cap. A stall hint
3420
+ * (no output for a while) surfaces on demand in `bg_status`.
3421
+ *
3422
+ * **Execution is pi's own.** Every task runs through pi's
3423
+ * `createLocalBashOperations().exec` — the same backend the built-in bash tool
3424
+ * uses — so shell config (`bash -c`), the agent's cwd, `getShellEnv()`, the
3425
+ * cwd-exists guard, process-tree kill, and orphan tracking all match pi's
3426
+ * built-in bash, by construction. We hold the exec promise rather than awaiting
3427
+ * it inline: it resolves on completion (→ the wake), and `bg_kill` / the
3428
+ * watchdog abort its signal (→ `killProcessTree`).
3429
+ *
3430
+ * **State is in-memory, scoped to the conversation.** One harness process
3431
+ * serves all of an agent's conversations, so task state is held in a
3432
+ * module-level map tagged with the conversation that started it. The sandbox
3433
+ * has no DB, so each session resolves its conversation once — lazily, api-side
3434
+ * from the origin messageId in its channel context (`resolveConversationFromApi`)
3435
+ * — and every run of the same conversation resolves to the same id, keeping the
3436
+ * shared map correctly scoped across turns. `bg_*`, the completion wake, and the
3437
+ * next-session injection all filter to the resolved conversation — an agent
3438
+ * never sees or is woken by a task from a different chat. Only the output log
3439
+ * spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
3440
+ * in memory; exit code and run state live on the in-memory task.
3441
+ *
3442
+ * **No cross-restart survival (v1, deliberate).** Task state lives only in
3443
+ * the running harness process. A harness restart (crash → supervisord
3444
+ * respawn, or `platform harness reload` after the agent edits its own
3445
+ * harness) drops the map and pi's exec children are reaped with it. We don't
3446
+ * resurrect from disk because the common next-run case cold-provisions a
3447
+ * *different* sandbox anyway (warm reuse is the minority in prod), so on-disk
3448
+ * state would rarely be the box the next run lands on. The idle-completion wake
3449
+ * does cross the sandbox → platform boundary (a fresh run via `bg-task-done`),
3450
+ * but a task whose harness dies before it finishes is gone — it is not
3451
+ * resurrected, and this stays distinct from the scheduled-run (cron) system.
3452
+ */
3453
+ const log = logger.child({ module: "background-tasks-ext" });
3454
+ const ops = createLocalBashOperations();
3455
+ function tasksDir() {
3456
+ return process.env.SKYDIVE_BG_TASKS_DIR ?? process.env.ANYONE_BG_TASKS_DIR ?? "/home/user/.anyone/bg-tasks";
3457
+ }
3458
+ const WATCHDOG_INTERVAL_MS = 3e4;
3459
+ const KEEPALIVE_EVERY_MS = 6e4;
3460
+ const KEEPALIVE_MAX_MS = 3600 * 1e3;
3461
+ const STALL_HINT_AFTER_MS = 120 * 1e3;
3462
+ const MAX_LOG_BYTES = 100 * 1024 * 1024;
3463
+ const DEFAULT_TAIL_LINES = 30;
3464
+ const TAIL_READ_BYTES = 64 * 1024;
3465
+ function taskLabel(meta) {
3466
+ return `${meta.id} "${meta.description ?? meta.command.slice(0, 60)}"`;
3467
+ }
3468
+ let taskCounter = 0;
3469
+ const tasks = /* @__PURE__ */ new Map();
3470
+ let watchdogInterval = null;
3471
+ let lastKeepaliveAt = 0;
3472
+ function sameConversation(meta, conversationId) {
3473
+ return meta.conversationId === conversationId;
3474
+ }
3475
+ function logPath(id) {
3476
+ return join(tasksDir(), `${id}.log`);
3477
+ }
3478
+ async function readLogChunk(id, maxBytes, anchor) {
3479
+ let fh = null;
3480
+ try {
3481
+ fh = await open(logPath(id), "r");
3482
+ const { size } = await fh.stat();
3483
+ const readBytes = Math.min(size, maxBytes);
3484
+ const offset = anchor === "tail" ? size - readBytes : 0;
3485
+ const buffer = Buffer.alloc(readBytes);
3486
+ await fh.read(buffer, 0, readBytes, offset);
3487
+ let start = 0;
3488
+ if (anchor === "tail" && readBytes < size) while (start < buffer.length && (buffer[start] & 192) === 128) start++;
3489
+ return {
3490
+ text: buffer.toString("utf8", start),
3491
+ size
3492
+ };
3493
+ } catch {
3494
+ return {
3495
+ text: "",
3496
+ size: 0
3497
+ };
3498
+ } finally {
3499
+ await fh?.close();
3500
+ }
3501
+ }
3502
+ function readLogEnd(id, maxBytes) {
3503
+ return readLogChunk(id, maxBytes, "tail");
3504
+ }
3505
+ async function tailLog(id, lines) {
3506
+ const { text } = await readLogEnd(id, TAIL_READ_BYTES);
3507
+ const all = text.split("\n");
3508
+ const tail = all.slice(Math.max(0, all.length - lines - 1)).join("\n");
3509
+ return tail.trim().length > 0 ? tail : "(no output yet)";
3510
+ }
3511
+ async function headLog(id, lines) {
3512
+ const { text } = await readLogChunk(id, TAIL_READ_BYTES, "head");
3513
+ const head = text.split("\n").slice(0, lines).join("\n");
3514
+ return head.trim().length > 0 ? head : "(no output yet)";
3515
+ }
3516
+ function secondsSinceLastOutput(meta) {
3517
+ return Math.round((Date.now() - meta.lastOutputAt) / 1e3);
3518
+ }
3519
+ async function describeStatus(meta, lines) {
3520
+ const killNote = meta.killedReason ? ` (killed: ${meta.killedReason})` : "";
3521
+ if (!meta.running) {
3522
+ if (meta.exitCode !== null) return `Task ${meta.id} finished with exit code ${meta.exitCode}${killNote}.\nLast output:\n${await tailLog(meta.id, lines)}`;
3523
+ const why = meta.killedReason ? "" : meta.error ? ` (${meta.error})` : " (process ended without an exit code — likely killed or the sandbox restarted)";
3524
+ return `Task ${meta.id} ended${killNote}${why}.\nLast output:\n${await tailLog(meta.id, lines)}`;
3525
+ }
3526
+ const runningForS = Math.round((Date.now() - meta.startedAt) / 1e3);
3527
+ const quietS = secondsSinceLastOutput(meta);
3528
+ const stallHint = quietS * 1e3 > STALL_HINT_AFTER_MS ? `\n⚠ No output for ${quietS}s — the command may be stalled or waiting for interactive input it will never get.` : "";
3529
+ return `Task ${meta.id} is running (${runningForS}s elapsed).${stallHint}\nLast output:\n${await tailLog(meta.id, lines)}`;
3530
+ }
3531
+ function watchdogTick(now) {
3532
+ const running = [...tasks.values()].filter((t) => t.running);
3533
+ if (running.length === 0) {
3534
+ stopWatchdog();
3535
+ return;
3536
+ }
3537
+ for (const meta of running) {
3538
+ const overRuntime = now - meta.startedAt > KEEPALIVE_MAX_MS;
3539
+ const overSize = meta.logBytes > MAX_LOG_BYTES;
3540
+ if (!overRuntime && !overSize) continue;
3541
+ const reason = overSize ? `output exceeded ${MAX_LOG_BYTES / (1024 * 1024)}MiB` : `exceeded ${KEEPALIVE_MAX_MS / 6e4}m max runtime`;
3542
+ log.warn({
3543
+ taskId: meta.id,
3544
+ reason
3545
+ }, "watchdog killing bg task");
3546
+ try {
3547
+ killTask(meta, reason);
3548
+ } catch (err) {
3549
+ log.warn({
3550
+ err,
3551
+ taskId: meta.id
3552
+ }, "watchdog kill failed");
3553
+ }
3554
+ }
3555
+ if (now - lastKeepaliveAt >= KEEPALIVE_EVERY_MS) {
3556
+ lastKeepaliveAt = now;
3557
+ postHeartbeat({ messageId: null });
3558
+ }
3559
+ }
3560
+ function stopWatchdog() {
3561
+ if (watchdogInterval) {
3562
+ clearInterval(watchdogInterval);
3563
+ watchdogInterval = null;
3564
+ }
3565
+ }
3566
+ function ensureWatchdog() {
3567
+ if (watchdogInterval) return;
3568
+ watchdogInterval = setInterval(() => watchdogTick(Date.now()), WATCHDOG_INTERVAL_MS);
3569
+ }
3570
+ function killTask(meta, reason) {
3571
+ if (reason) meta.killedReason = reason;
3572
+ meta.controller.abort();
3573
+ }
3574
+ function createBackgroundTasksExtension({ channelContext }) {
3575
+ return (pi) => {
3576
+ const messageId = extractMessageId(channelContext);
3577
+ let conversationId = null;
3578
+ let conversationIdPromise = null;
3579
+ function ensureConversationId() {
3580
+ if (!messageId) return Promise.resolve(null);
3581
+ return conversationIdPromise ??= resolveConversationFromApi(messageId).then((id) => {
3582
+ conversationId = id;
3583
+ return id;
3584
+ });
3585
+ }
3586
+ let agentActive = false;
3587
+ pi.on("agent_start", async () => {
3588
+ agentActive = true;
3589
+ });
3590
+ pi.on("agent_end", async () => {
3591
+ agentActive = false;
3592
+ });
3593
+ async function taskDoneMessage(meta) {
3594
+ const code = meta.exitCode;
3595
+ const status = meta.killedReason ? "killed" : code === 0 ? "completed" : code === null ? "finished" : "failed";
3596
+ const codeSuffix = code !== null ? ` (exit code ${code})` : "";
3597
+ const killNote = meta.killedReason ? `\n<kill-reason>${meta.killedReason}</kill-reason>` : "";
3598
+ const recentOutput = await tailLog(meta.id, 20);
3599
+ return {
3600
+ customType: "anyone-bg-task-done",
3601
+ content: `<background-task-finished>
3602
+ <task-id>${meta.id}</task-id>
3603
+ <status>${status}</status>
3604
+ <exit-code>${code ?? "unknown"}</exit-code>
3605
+ <command>${meta.command}</command>
3606
+ <summary>Background task ${taskLabel(meta)} ${status}${codeSuffix}</summary>${killNote}
3607
+ <recent-output>
3608
+ ${recentOutput}
3609
+ </recent-output>
3610
+ </background-task-finished>
3611
+ Run bg_logs for the full output.
3612
+
3613
+ 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.`,
3614
+ display: false
3615
+ };
3616
+ }
3617
+ async function notifyCompletion(meta) {
3618
+ if (meta.notified) return;
3619
+ if (agentActive && sameConversation(meta, conversationId)) {
3620
+ meta.notified = true;
3621
+ pi.sendMessage(await taskDoneMessage(meta), {
3622
+ triggerTurn: true,
3623
+ deliverAs: "followUp"
3624
+ });
3625
+ log.info({
3626
+ taskId: meta.id,
3627
+ conversationId
3628
+ }, "bg task completion delivered live (followUp)");
3629
+ return;
3630
+ }
3631
+ if (meta.messageId) {
3632
+ meta.notified = true;
3633
+ log.info({
3634
+ taskId: meta.id,
3635
+ messageId: meta.messageId
3636
+ }, "posting idle bg-task-done wake");
3637
+ const message = await taskDoneMessage(meta);
3638
+ postBackgroundTaskDone({
3639
+ messageId: meta.messageId,
3640
+ content: message.content
3641
+ }).catch((err) => {
3642
+ meta.notified = false;
3643
+ log.warn({
3644
+ err,
3645
+ taskId: meta.id
3646
+ }, "bg-task-done wake failed; will retry at next session_start");
3647
+ });
3648
+ } else log.info({ taskId: meta.id }, "bg task completed idle with no origin message; deferring to next session_start");
3649
+ }
3650
+ async function launchTask({ command, description, cwd }) {
3651
+ taskCounter += 1;
3652
+ const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
3653
+ try {
3654
+ await mkdir(tasksDir(), { recursive: true });
3655
+ } catch (err) {
3656
+ log.error({
3657
+ err,
3658
+ taskId: id
3659
+ }, "background task dir unavailable");
3660
+ }
3661
+ const logStream = createWriteStream(logPath(id), { flags: "a" });
3662
+ logStream.on("error", (err) => {
3663
+ log.warn({
3664
+ err,
3665
+ taskId: id
3666
+ }, "bg task log write failed");
3667
+ });
3668
+ const startedAt = Date.now();
3669
+ const meta = {
3670
+ id,
3671
+ command: stripPlatformExportsForDisplay(command),
3672
+ startedAt,
3673
+ logBytes: 0,
3674
+ lastOutputAt: startedAt,
3675
+ conversationId,
3676
+ messageId,
3677
+ description,
3678
+ notified: false,
3679
+ killedReason: null,
3680
+ controller: new AbortController(),
3681
+ running: true,
3682
+ exitCode: null,
3683
+ error: null
3684
+ };
3685
+ tasks.set(id, meta);
3686
+ ops.exec(command, cwd, {
3687
+ onData: (chunk) => {
3688
+ meta.logBytes += chunk.length;
3689
+ meta.lastOutputAt = Date.now();
3690
+ logStream.write(chunk);
3691
+ },
3692
+ signal: meta.controller.signal
3693
+ }).then((r) => {
3694
+ meta.exitCode = r.exitCode;
3695
+ }).catch((err) => {
3696
+ if (!meta.controller.signal.aborted) {
3697
+ meta.error = err instanceof Error ? err.message : String(err);
3698
+ log.warn({
3699
+ err,
3700
+ taskId: id
3701
+ }, "bg task exec error");
3702
+ }
3703
+ }).finally(async () => {
3704
+ logStream.end();
3705
+ await finished(logStream).catch(() => {});
3706
+ meta.running = false;
3707
+ log.info({
3708
+ taskId: id,
3709
+ exitCode: meta.exitCode
3710
+ }, "bg task finished");
3711
+ await notifyCompletion(meta);
3712
+ });
3713
+ ensureWatchdog();
3714
+ log.info({
3715
+ taskId: id,
3716
+ conversationId
3717
+ }, "bg task started");
3718
+ return meta;
3719
+ }
3720
+ function knownTaskIds() {
3721
+ return [...tasks.values()].filter((t) => sameConversation(t, conversationId)).map((t) => t.id).join(", ") || "(none)";
3722
+ }
3723
+ pi.on("session_start", async () => {
3724
+ if (tasks.size === 0) return;
3725
+ await ensureConversationId();
3726
+ for (const [id, meta] of tasks) if (sameConversation(meta, conversationId) && !meta.running && meta.notified) {
3727
+ tasks.delete(id);
3728
+ await unlink(logPath(id)).catch(() => {});
3729
+ }
3730
+ const unnotified = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && !t.notified && !t.running);
3731
+ for (const meta of unnotified) {
3732
+ meta.notified = true;
3733
+ pi.sendMessage(await taskDoneMessage(meta));
3734
+ }
3735
+ if (unnotified.length > 0) log.info({
3736
+ conversationId,
3737
+ count: unnotified.length
3738
+ }, "injected completed bg tasks at session_start");
3739
+ if ([...tasks.values()].some((t) => sameConversation(t, conversationId) && t.running)) ensureWatchdog();
3740
+ });
3741
+ function err(text) {
3742
+ return {
3743
+ error: {
3744
+ content: [{
3745
+ type: "text",
3746
+ text
3747
+ }],
3748
+ details: {},
3749
+ isError: true
3750
+ },
3751
+ meta: null
3752
+ };
3753
+ }
3754
+ function resolveTask(taskId) {
3755
+ const exact = tasks.get(taskId);
3756
+ if (exact && sameConversation(exact, conversationId)) return {
3757
+ error: null,
3758
+ meta: exact
3759
+ };
3760
+ const matches = [...tasks.values()].filter((t) => sameConversation(t, conversationId) && t.id.startsWith(taskId));
3761
+ if (matches.length === 1) return {
3762
+ error: null,
3763
+ meta: matches[0]
3764
+ };
3765
+ if (matches.length > 1) return err(`Ambiguous task prefix "${taskId}" matches: ${matches.map((t) => t.id).join(", ")}`);
3766
+ return err(`Unknown task ${taskId}. Known tasks: ${knownTaskIds()}`);
3767
+ }
3768
+ function listTasks() {
3769
+ const mine = [...tasks.values()].filter((t) => sameConversation(t, conversationId));
3770
+ if (mine.length === 0) return "No background tasks.";
3771
+ return mine.map((t) => {
3772
+ const state = t.running ? "running" : t.exitCode !== null ? `exited ${t.exitCode}${t.killedReason ? ` (killed: ${t.killedReason})` : ""}` : t.killedReason ? `killed: ${t.killedReason}` : "ended";
3773
+ const elapsed = Math.round((Date.now() - t.startedAt) / 1e3);
3774
+ const cmd = t.description ? ` — ${t.command.slice(0, 60)}` : "";
3775
+ return `${taskLabel(t)} — ${state}, ${elapsed}s${cmd}`;
3776
+ }).join("\n");
3777
+ }
3778
+ const taskIdParam = Type.Object({ taskId: Type.String({ description: "Task id from bg_run" }) });
3779
+ const bgRun = {
3780
+ name: "bg_run",
3781
+ label: "Run in background",
3782
+ 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.",
3783
+ promptSnippet: "bg_run — run a long command without blocking; you are notified on completion",
3784
+ parameters: Type.Object({
3785
+ command: Type.String({ description: "Bash command to execute" }),
3786
+ description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." }))
3787
+ }),
3788
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3789
+ await ensureConversationId();
3790
+ const { command, description = null } = params;
3791
+ const meta = await launchTask({
3792
+ command,
3793
+ description,
3794
+ cwd: ctx.cwd
3795
+ });
3796
+ return {
3797
+ content: [{
3798
+ type: "text",
3799
+ 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}"}.`
3800
+ }],
3801
+ details: {}
3802
+ };
3803
+ }
3804
+ };
3805
+ const bgStatus = {
3806
+ name: "bg_status",
3807
+ label: "Background task status",
3808
+ description: "Report whether a background task is running or finished, with its exit code and recent output (and a stall warning if it has gone quiet).",
3809
+ parameters: Type.Object({
3810
+ taskId: Type.Optional(Type.String({ description: "Task id or unambiguous prefix. Omit to list all your tasks." })),
3811
+ lines: Type.Optional(Type.Number({ description: "Lines of recent output to include" }))
3812
+ }),
3813
+ async execute(_toolCallId, params) {
3814
+ await ensureConversationId();
3815
+ const { taskId, lines = DEFAULT_TAIL_LINES } = params;
3816
+ if (!taskId) return {
3817
+ content: [{
3818
+ type: "text",
3819
+ text: listTasks()
3820
+ }],
3821
+ details: {}
3822
+ };
3823
+ const resolved = resolveTask(taskId);
3824
+ if (resolved.error) return resolved.error;
3825
+ return {
3826
+ content: [{
3827
+ type: "text",
3828
+ text: await describeStatus(resolved.meta, lines)
3829
+ }],
3830
+ details: {}
3831
+ };
3832
+ }
3833
+ };
3834
+ const bgLogs = {
3835
+ name: "bg_logs",
3836
+ label: "Background task logs",
3837
+ description: "Show a background task's output log — the last lines by default, or the first lines with tail=false.",
3838
+ parameters: Type.Object({
3839
+ taskId: Type.String({ description: "Task id or unambiguous prefix" }),
3840
+ lines: Type.Optional(Type.Number({ description: "Number of lines" })),
3841
+ tail: Type.Optional(Type.Boolean({ description: "Last lines (default) or first when false" }))
3842
+ }),
3843
+ async execute(_toolCallId, params) {
3844
+ await ensureConversationId();
3845
+ const { taskId, lines = DEFAULT_TAIL_LINES, tail = true } = params;
3846
+ const resolved = resolveTask(taskId);
3847
+ if (resolved.error) return resolved.error;
3848
+ return {
3849
+ content: [{
3850
+ type: "text",
3851
+ text: tail ? await tailLog(resolved.meta.id, lines) : await headLog(resolved.meta.id, lines)
3852
+ }],
3853
+ details: {}
3854
+ };
3855
+ }
3856
+ };
3857
+ const bgKill = {
3858
+ name: "bg_kill",
3859
+ label: "Kill background task",
3860
+ description: "Terminate a running background task and its whole process group.",
3861
+ parameters: taskIdParam,
3862
+ async execute(_toolCallId, params) {
3863
+ await ensureConversationId();
3864
+ const { taskId } = params;
3865
+ const resolved = resolveTask(taskId);
3866
+ if (resolved.error) return resolved.error;
3867
+ const meta = resolved.meta;
3868
+ if (!meta.running) return {
3869
+ content: [{
3870
+ type: "text",
3871
+ text: `Task ${taskId} is not running.\n${await describeStatus(meta, 10)}`
3872
+ }],
3873
+ details: {}
3874
+ };
3875
+ try {
3876
+ killTask(meta, null);
3877
+ meta.notified = true;
3878
+ return {
3879
+ content: [{
3880
+ type: "text",
3881
+ text: `Killed task ${taskId}.`
3882
+ }],
3883
+ details: {}
3884
+ };
3885
+ } catch (killErr) {
3886
+ return {
3887
+ content: [{
3888
+ type: "text",
3889
+ text: `Failed to kill task ${taskId}: ${killErr instanceof Error ? killErr.message : String(killErr)}`
3890
+ }],
3891
+ details: {},
3892
+ isError: true
3893
+ };
3894
+ }
3895
+ }
3896
+ };
3897
+ pi.registerTool(bgRun);
3898
+ pi.registerTool(bgStatus);
3899
+ pi.registerTool(bgLogs);
3900
+ pi.registerTool(bgKill);
3901
+ };
3902
+ }
3903
+ //#endregion
3904
+ //#region src/extensions/index.ts
3905
+ /**
3906
+ * The static (config-free) agent extensions, in load order. `memoryExtension`
3907
+ * is the generic, agent-owned slice (`projects/`/`feedback/`/`reference/`); the
3908
+ * per-user `users/` slice is rendered by the platform memory extension inside
3909
+ * `platformExtensions()`, which can resolve the current user.
3910
+ */
3911
+ const all = [
3912
+ currentTimeExtension,
3913
+ soulExtension,
3914
+ memoryExtension,
3915
+ mcp_default,
3916
+ localToolsExtension,
3917
+ toolCallEnvExtension,
3918
+ bashDefaultTimeoutExtension,
3919
+ toolCallSummaryExtension
3920
+ ];
3921
+ /**
3922
+ * Platform-owned extensions — daemon session tracking, sandbox heartbeats,
3923
+ * OTel self-tracing, and background tasks. These are platform capabilities
3924
+ * every session must include (not agent-authored content); the session
3925
+ * factory appends them to the agent-chosen set.
3926
+ */
3927
+ function platformExtensions({ sessionId, channelContext }) {
3928
+ return [
3929
+ createPlatformExtensions({
3930
+ sessionId,
3931
+ channelContext
3932
+ }),
3933
+ createPlatformMemoryExtension({ channelContext }),
3934
+ selfTraceExtension,
3935
+ createBackgroundTasksExtension({ channelContext }),
3936
+ createSubagentExtension({ channelContext }),
3937
+ createContextManagementExtension(),
3938
+ resourcePressureWarningExtension
3939
+ ];
3940
+ }
3941
+ //#endregion
3942
+ export { all, createHarness, createHealthHandler, createPlatformEnvMiddleware, installToolUpdateAutoStop, isPlatformConfigLoaded, loadPlatformConfig, localToolsExtension, mcp_default as mcpExtension, memoryExtension, platformExtensions, runToolUpdateLoop, soulExtension, toolCallEnvExtension };