@skydiveai/pi-extensions 0.1.0

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