@desplega.ai/agent-swarm 1.90.0 → 1.91.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.
Files changed (52) hide show
  1. package/openapi.json +74 -1
  2. package/package.json +5 -5
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/memory/providers/sqlite-store.ts +6 -1
  5. package/src/be/memory/types.ts +1 -0
  6. package/src/be/scripts/typecheck.ts +132 -1
  7. package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
  8. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  9. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  10. package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
  11. package/src/be/seed-scripts/index.ts +36 -0
  12. package/src/commands/artifact.ts +3 -2
  13. package/src/commands/profile-sync.ts +310 -0
  14. package/src/commands/runner.ts +91 -1
  15. package/src/hooks/hook.ts +32 -9
  16. package/src/http/index.ts +47 -0
  17. package/src/http/integrations.ts +6 -1
  18. package/src/http/mcp-bridge.ts +117 -0
  19. package/src/http/mcp-oauth.ts +97 -39
  20. package/src/http/memory.ts +5 -2
  21. package/src/http/openapi.ts +2 -2
  22. package/src/http/pages-public.ts +10 -11
  23. package/src/http/pages.ts +7 -11
  24. package/src/http/scripts.ts +24 -1
  25. package/src/http/utils.ts +11 -4
  26. package/src/jira/app.ts +2 -3
  27. package/src/jira/webhook-lifecycle.ts +2 -1
  28. package/src/linear/app.ts +2 -3
  29. package/src/providers/claude-adapter.ts +26 -0
  30. package/src/scripts-runtime/executors/native.ts +1 -0
  31. package/src/scripts-runtime/sdk-allowlist.ts +121 -0
  32. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  33. package/src/scripts-runtime/types/stdlib.d.ts +227 -0
  34. package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
  35. package/src/tests/claude-adapter-otel.test.ts +85 -1
  36. package/src/tests/hook-registration-nudge.test.ts +69 -0
  37. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  38. package/src/tests/pages-public-html.test.ts +41 -0
  39. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  40. package/src/tests/profile-sync.test.ts +282 -0
  41. package/src/tests/scripts-runtime.test.ts +33 -0
  42. package/src/tests/seed-scripts.test.ts +2 -2
  43. package/src/tools/create-metric.ts +2 -3
  44. package/src/tools/create-page.ts +3 -6
  45. package/src/tools/memory-rate.ts +2 -1
  46. package/src/tools/memory-search.ts +1 -0
  47. package/src/tools/register-kapso-number.ts +2 -4
  48. package/src/tools/request-human-input.ts +2 -1
  49. package/src/tools/script-common.ts +2 -4
  50. package/src/tools/script-run.ts +7 -0
  51. package/src/utils/constants.ts +58 -8
  52. package/templates/skills/swarm-scripts/content.md +46 -7
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+
3
+ export const argsSchema = z.object({
4
+ days: z
5
+ .number()
6
+ .int()
7
+ .positive()
8
+ .optional()
9
+ .describe("Look back this many days (default 7)"),
10
+ agentId: z.string().optional().describe("Filter by agent ID (default: all agents)"),
11
+ limit: z
12
+ .number()
13
+ .int()
14
+ .positive()
15
+ .optional()
16
+ .describe("Top N tools to return (default 20)"),
17
+ });
18
+
19
+ /** Tool usage histogram from session_logs — top tools by call count. */
20
+ export default async function toolUsage(args: any, ctx: any) {
21
+ const parsed = argsSchema.safeParse(args);
22
+ if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
23
+ const days = parsed.data.days || 7;
24
+ const limit = parsed.data.limit || 20;
25
+ const agentId = parsed.data.agentId;
26
+
27
+ const agentFilter = agentId ? `AND agent_id = '${agentId}'` : "";
28
+ const query = `
29
+ SELECT tool_name, count(*) as calls
30
+ FROM session_logs
31
+ WHERE tool_name IS NOT NULL
32
+ AND created_at > datetime('now', '-${days} days')
33
+ ${agentFilter}
34
+ GROUP BY tool_name
35
+ ORDER BY calls DESC
36
+ LIMIT ${limit}
37
+ `;
38
+
39
+ const res: any = await ctx.swarm.db_query({ query });
40
+ const payload = res?.data ?? res;
41
+ const rows: any[] = payload?.rows ?? [];
42
+
43
+ const totalCalls = rows.reduce((sum: number, r: any) => sum + (r.calls ?? 0), 0);
44
+
45
+ return {
46
+ days,
47
+ agentId: agentId ?? "all",
48
+ totalDistinctTools: rows.length,
49
+ totalCalls,
50
+ tools: rows.map((r: any) => ({
51
+ tool: r.tool_name,
52
+ calls: r.calls,
53
+ pct: totalCalls > 0 ? Math.round((r.calls / totalCalls) * 1000) / 10 : 0,
54
+ })),
55
+ };
56
+ }
@@ -22,6 +22,7 @@ import { getScript, upsertScriptByName } from "../scripts/db";
22
22
  import { extractArgsJsonSchema } from "../scripts/extract-schema";
23
23
  import { typecheckScript } from "../scripts/typecheck";
24
24
  import type { Seeder, SeedItem } from "../seed/types";
25
+ import compoundInsightsSrc from "./catalog/compound-insights.ts" with { type: "text" };
25
26
  import dateResolveSrc from "./catalog/date-resolve.ts" with { type: "text" };
26
27
  import fetchReadableSrc from "./catalog/fetch-readable.ts" with { type: "text" };
27
28
  import ghPrSnapshotSrc from "./catalog/gh-pr-snapshot.ts" with { type: "text" };
@@ -29,9 +30,12 @@ import groupCountSrc from "./catalog/group-count.ts" with { type: "text" };
29
30
  import jsonQuerySrc from "./catalog/json-query.ts" with { type: "text" };
30
31
  import linearIssueSrc from "./catalog/linear-issue.ts" with { type: "text" };
31
32
  import memoryDedupCheckSrc from "./catalog/memory-dedup-check.ts" with { type: "text" };
33
+ import scheduleHealthSrc from "./catalog/schedule-health.ts" with { type: "text" };
32
34
  import slackThreadFlattenSrc from "./catalog/slack-thread-flatten.ts" with { type: "text" };
35
+ import smartRecallSrc from "./catalog/smart-recall.ts" with { type: "text" };
33
36
  import taskFailureAuditSrc from "./catalog/task-failure-audit.ts" with { type: "text" };
34
37
  import textDiffSrc from "./catalog/text-diff.ts" with { type: "text" };
38
+ import toolUsageSrc from "./catalog/tool-usage.ts" with { type: "text" };
35
39
 
36
40
  export type SeedScript = {
37
41
  name: string;
@@ -122,6 +126,38 @@ export const SEED_SCRIPTS: SeedScript[] = [
122
126
  intent: "Turn a Slack thread into plain text for summarizing or as task context.",
123
127
  source: asText(slackThreadFlattenSrc),
124
128
  },
129
+ {
130
+ name: "smart-recall",
131
+ description:
132
+ "Multi-query fan-out memory search with dedup and composite reranking (bestSimilarity + 0.05 * hitCount). Returns unique memories across all queries.",
133
+ intent:
134
+ "Recall relevant memories using multiple search angles — better coverage than a single query. Use for task onboarding, context gathering, or before writing new memories.",
135
+ source: asText(smartRecallSrc),
136
+ },
137
+ {
138
+ name: "schedule-health",
139
+ description:
140
+ "Per-schedule failure rate check over recent tasks — flags schedules with failure rates above a configurable threshold.",
141
+ intent:
142
+ "Find unhealthy schedules that keep failing — for daily compounding, reliability reviews, or ops triage.",
143
+ source: asText(scheduleHealthSrc),
144
+ },
145
+ {
146
+ name: "tool-usage",
147
+ description:
148
+ "Tool usage histogram from session_logs — top tools by call count over a time window, optionally filtered by agent.",
149
+ intent:
150
+ "See which MCP tools agents use most — for SDK gap analysis, optimization, or daily ops snapshots.",
151
+ source: asText(toolUsageSrc),
152
+ },
153
+ {
154
+ name: "compound-insights",
155
+ description:
156
+ "All-in-one swarm-wide daily ops snapshot: task completion/failure summary, real failure clusters (excludes superseded/cancelled bookkeeping), schedule health flags, tool usage top-25, memory health stats, and a per-agent breakdown. Aggregates across ALL agents via direct read-only SQL.",
157
+ intent:
158
+ "Single-call daily compounding Phase 0 helper — replaces ~25 raw tool roundtrips with one compressed JSON result covering every agent. For daily evolution, ops reviews, or heartbeat context.",
159
+ source: asText(compoundInsightsSrc),
160
+ },
125
161
  ];
126
162
 
127
163
  /** A catalog entry resolved into a generic {@link SeedItem}. */
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { createArtifactServer } from "../artifact-sdk";
3
3
  import { getApiKey } from "../utils/api-key";
4
+ import { getMcpBaseUrl } from "../utils/constants";
4
5
 
5
6
  interface ArtifactArgs {
6
7
  additionalArgs: string[];
@@ -139,7 +140,7 @@ async function artifactServe(args: ArtifactArgs) {
139
140
 
140
141
  async function artifactList() {
141
142
  const apiKey = getApiKey();
142
- const mcpBaseUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
143
+ const mcpBaseUrl = getMcpBaseUrl();
143
144
  const agentId = process.env.AGENT_ID || "";
144
145
 
145
146
  try {
@@ -197,7 +198,7 @@ async function artifactStop(args: ArtifactArgs) {
197
198
  }
198
199
 
199
200
  const apiKey = getApiKey();
200
- const mcpBaseUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
201
+ const mcpBaseUrl = getMcpBaseUrl();
201
202
  const agentId = process.env.AGENT_ID || "";
202
203
 
203
204
  // 1. Try to stop PM2 process
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Harness-agnostic FS → DB profile sync (worker-side, HTTP-only).
3
+ *
4
+ * Persists an agent's self-editable identity / config files back to the API:
5
+ * - SOUL.md / IDENTITY.md / TOOLS.md / HEARTBEAT.md (bundled identity POST)
6
+ * - ~/.claude/CLAUDE.md (claude POST)
7
+ * - /workspace/start-up.sh (agent-managed section) (setup POST)
8
+ *
9
+ * This mirrors the per-session sync that the Claude plugin hooks
10
+ * (`src/hooks/hook.ts`) and the pi extension (`src/providers/pi-mono-extension.ts`)
11
+ * already perform, but lifted into a single shared module the runner can call
12
+ * at session end for ANY `hasLocalEnvironment` harness (claude, pi, codex,
13
+ * opencode). Before this module, codex/opencode had no sync path at all and
14
+ * pi's path could silently not-fire (2026-06-01 regression).
15
+ *
16
+ * Boundary rules (enforced by CI):
17
+ * - MUST NOT import `src/be/db` or `bun:sqlite` (worker/API DB boundary —
18
+ * `scripts/check-db-boundary.sh`). This module is HTTP-only.
19
+ * - MUST NOT read the API key from `process.env` directly
20
+ * (`scripts/check-api-key-boundary.sh`). The caller passes the key
21
+ * (resolved via `getApiKey()`) in `opts.apiKey`.
22
+ *
23
+ * Hardening vs. the original copies: every POST checks `resp.ok` and surfaces
24
+ * a scrubbed warning on a non-2xx response or thrown error instead of
25
+ * silently swallowing it (the swallow is exactly what hid the 2026-06-01 pi
26
+ * drop). The sync stays NON-FATAL — a failed sync must never fail the task —
27
+ * but it must be VISIBLE.
28
+ */
29
+
30
+ import { scrubSecrets } from "../utils/secret-scrubber.ts";
31
+
32
+ export const SOUL_MD_PATH = "/workspace/SOUL.md";
33
+ export const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
34
+ export const TOOLS_MD_PATH = "/workspace/TOOLS.md";
35
+ export const HEARTBEAT_MD_PATH = "/workspace/HEARTBEAT.md";
36
+ export const SETUP_SCRIPT_PATH = "/workspace/start-up.sh";
37
+ /**
38
+ * Claude Code's personal-file CLAUDE.md path. This is what the Claude plugin
39
+ * Stop hook reads and owns — the runner only uses it as a backstop for an
40
+ * all-Claude batch (never overwriting it with the workspace materialization).
41
+ */
42
+ export const CLAUDE_MD_PATH = `${process.env.HOME}/.claude/CLAUDE.md`;
43
+ /**
44
+ * Workspace CLAUDE.md — the agent-level instructions file the runner
45
+ * materializes from the `claudeMd` DB field at boot (`runner.ts`) and that the
46
+ * base-prompt truncation notice tells NON-Claude harnesses (codex/pi/opencode)
47
+ * to edit. Distinct from CLAUDE_MD_PATH; this is the FS→DB source for the
48
+ * non-Claude providers that previously had no sync path at all.
49
+ */
50
+ export const WORKSPACE_CLAUDE_MD_PATH = "/workspace/CLAUDE.md";
51
+
52
+ // Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption.
53
+ // Mirrors `hook.ts` (raised from 100 to 500 after profile-corruption recurrences
54
+ // where a short test sentinel synced into the real agent's DB row).
55
+ const IDENTITY_FILE_MIN_LENGTH = 500;
56
+ // Maximum file size we are willing to sync (>64KB is almost certainly not a
57
+ // hand-edited identity/config file).
58
+ const MAX_FILE_LENGTH = 65536;
59
+
60
+ const SETUP_MARKER_START = "# === Agent-managed setup (from DB) ===";
61
+ const SETUP_MARKER_END = "# === End agent-managed setup ===";
62
+
63
+ export type ProfileSyncField = "identity" | "claude" | "setup";
64
+ export type ProfileChangeSource = "self_edit" | "session_sync";
65
+
66
+ export interface ProfileSyncOptions {
67
+ agentId: string;
68
+ apiUrl: string;
69
+ apiKey: string;
70
+ /** Session-end sync uses "session_sync"; on-edit hooks use "self_edit". */
71
+ changeSource?: ProfileChangeSource;
72
+ /** Subset of field groups to sync. Defaults to all three. */
73
+ fields?: ProfileSyncField[];
74
+ /**
75
+ * Path to read the CLAUDE.md source from. Defaults to CLAUDE_MD_PATH (Claude
76
+ * Code's personal-file path). Non-Claude local harnesses must pass
77
+ * WORKSPACE_CLAUDE_MD_PATH so their `/workspace/CLAUDE.md` edits sync. See
78
+ * `resolveClaudeMdPath`.
79
+ */
80
+ claudeMdPath?: string;
81
+ /** Injectable fetch for tests. Defaults to the global `fetch`. */
82
+ fetchImpl?: typeof fetch;
83
+ }
84
+
85
+ /**
86
+ * Choose which CLAUDE.md source the runner should sync, given the harness
87
+ * providers of the completed local sessions in a batch. Claude Code's personal
88
+ * file lives at `~/.claude/CLAUDE.md` (CLAUDE_MD_PATH — the Stop hook's path);
89
+ * every other local harness edits `/workspace/CLAUDE.md` (the file the runner
90
+ * materializes and the base prompt points them to). When a batch mixes
91
+ * providers, the presence of any non-Claude session means the workspace file is
92
+ * the edited source of truth; an all-Claude batch uses the personal-file path,
93
+ * where the runner only acts as a backstop for a Stop hook that didn't fire and
94
+ * never clobbers a real personal-file edit with the stale workspace copy.
95
+ */
96
+ export function resolveClaudeMdPath(completedProviders: readonly string[]): string {
97
+ const anyNonClaude = completedProviders.some((p) => p !== "claude");
98
+ return anyNonClaude ? WORKSPACE_CLAUDE_MD_PATH : CLAUDE_MD_PATH;
99
+ }
100
+
101
+ /** A single profile-update POST body, tagged with a label for logging. */
102
+ interface ProfilePayload {
103
+ label: string;
104
+ body: Record<string, unknown>;
105
+ }
106
+
107
+ /**
108
+ * Pure: given the raw `start-up.sh` contents, return the agent-managed content
109
+ * to sync, or `null` if there is nothing syncable. Extracts ONLY the content
110
+ * between the agent-managed markers when present (so operator content isn't
111
+ * duplicated); otherwise treats the whole file (minus a leading shebang) as
112
+ * agent-managed.
113
+ */
114
+ export function extractSetupScriptContent(raw: string): string | null {
115
+ if (!raw.trim()) return null;
116
+
117
+ const startIdx = raw.indexOf(SETUP_MARKER_START);
118
+ const endIdx = raw.indexOf(SETUP_MARKER_END);
119
+
120
+ let content: string;
121
+ if (startIdx !== -1 && endIdx !== -1) {
122
+ // Markers present — extract ONLY the content between them.
123
+ content = raw.substring(startIdx + SETUP_MARKER_START.length, endIdx).trim();
124
+ } else {
125
+ // No markers — agent created/replaced the entire file. Store as-is minus shebang.
126
+ content = raw.replace(/^#!\/bin\/bash\n/, "").trim();
127
+ }
128
+
129
+ if (!content || content.length > MAX_FILE_LENGTH) return null;
130
+ return content;
131
+ }
132
+
133
+ /**
134
+ * Pure: build the bundled identity-update body from raw file contents. Applies
135
+ * the trim / max-length guards and the SOUL/IDENTITY min-length guard. Returns
136
+ * an empty object when nothing is syncable (callers should skip the POST).
137
+ * `undefined` inputs mean the file was absent.
138
+ */
139
+ export function buildIdentityPayload(files: {
140
+ soulMd?: string;
141
+ identityMd?: string;
142
+ toolsMd?: string;
143
+ heartbeatMd?: string;
144
+ }): Record<string, string> {
145
+ const updates: Record<string, string> = {};
146
+
147
+ if (files.soulMd !== undefined) {
148
+ const content = files.soulMd;
149
+ if (content.trim() && content.length <= MAX_FILE_LENGTH) {
150
+ if (content.length < IDENTITY_FILE_MIN_LENGTH) {
151
+ console.error(
152
+ `[profile-sync] Skipping SOUL.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
153
+ );
154
+ } else {
155
+ updates.soulMd = content;
156
+ }
157
+ }
158
+ }
159
+
160
+ if (files.identityMd !== undefined) {
161
+ const content = files.identityMd;
162
+ if (content.trim() && content.length <= MAX_FILE_LENGTH) {
163
+ if (content.length < IDENTITY_FILE_MIN_LENGTH) {
164
+ console.error(
165
+ `[profile-sync] Skipping IDENTITY.md sync: content too short (${content.length} chars, minimum ${IDENTITY_FILE_MIN_LENGTH}). This prevents accidental profile corruption.`,
166
+ );
167
+ } else {
168
+ updates.identityMd = content;
169
+ }
170
+ }
171
+ }
172
+
173
+ if (files.toolsMd !== undefined) {
174
+ const content = files.toolsMd;
175
+ if (content.trim() && content.length <= MAX_FILE_LENGTH) {
176
+ updates.toolsMd = content;
177
+ }
178
+ }
179
+
180
+ if (files.heartbeatMd !== undefined) {
181
+ const content = files.heartbeatMd;
182
+ if (content.length <= MAX_FILE_LENGTH) {
183
+ updates.heartbeatMd = content;
184
+ }
185
+ }
186
+
187
+ return updates;
188
+ }
189
+
190
+ /** Reads a file's text, returning `undefined` when it does not exist. */
191
+ export type FileReader = (path: string) => Promise<string | undefined>;
192
+
193
+ /** Default file reader — reads from the worker's local FS via Bun. */
194
+ async function readFileIfExists(path: string): Promise<string | undefined> {
195
+ try {
196
+ const file = Bun.file(path);
197
+ if (!(await file.exists())) return undefined;
198
+ return await file.text();
199
+ } catch {
200
+ return undefined;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Collect the profile-update POST bodies to send. Each entry is one POST.
206
+ * `fields` selects which groups to include. The file reader is injectable so
207
+ * the field-selection / guard logic can be unit-tested without touching the FS.
208
+ */
209
+ export async function collectProfilePayloads(
210
+ fields: ProfileSyncField[],
211
+ changeSource: ProfileChangeSource,
212
+ readFile: FileReader = readFileIfExists,
213
+ claudeMdPath: string = CLAUDE_MD_PATH,
214
+ ): Promise<ProfilePayload[]> {
215
+ const payloads: ProfilePayload[] = [];
216
+
217
+ if (fields.includes("identity")) {
218
+ const updates = buildIdentityPayload({
219
+ soulMd: await readFile(SOUL_MD_PATH),
220
+ identityMd: await readFile(IDENTITY_MD_PATH),
221
+ toolsMd: await readFile(TOOLS_MD_PATH),
222
+ heartbeatMd: await readFile(HEARTBEAT_MD_PATH),
223
+ });
224
+ if (Object.keys(updates).length > 0) {
225
+ payloads.push({ label: "identity", body: { ...updates, changeSource } });
226
+ }
227
+ }
228
+
229
+ if (fields.includes("claude")) {
230
+ const raw = await readFile(claudeMdPath);
231
+ if (raw?.trim() && raw.length <= MAX_FILE_LENGTH) {
232
+ payloads.push({ label: "claude", body: { claudeMd: raw, changeSource } });
233
+ }
234
+ }
235
+
236
+ if (fields.includes("setup")) {
237
+ const raw = await readFile(SETUP_SCRIPT_PATH);
238
+ if (raw !== undefined) {
239
+ const content = extractSetupScriptContent(raw);
240
+ if (content !== null) {
241
+ payloads.push({ label: "setup", body: { setupScript: content, changeSource } });
242
+ }
243
+ }
244
+ }
245
+
246
+ return payloads;
247
+ }
248
+
249
+ /**
250
+ * POST a single profile update. NON-FATAL but VISIBLE: a non-2xx response or a
251
+ * thrown error is logged (scrubbed) and swallowed so it never fails the task,
252
+ * but — unlike the original copies — it is never silently ignored.
253
+ */
254
+ export async function postProfileUpdate(
255
+ opts: Pick<ProfileSyncOptions, "agentId" | "apiUrl" | "apiKey" | "fetchImpl">,
256
+ payload: ProfilePayload,
257
+ ): Promise<void> {
258
+ const doFetch = opts.fetchImpl ?? fetch;
259
+ try {
260
+ const resp = await doFetch(`${opts.apiUrl}/api/agents/${opts.agentId}/profile`, {
261
+ method: "PUT",
262
+ headers: {
263
+ "Content-Type": "application/json",
264
+ Authorization: `Bearer ${opts.apiKey}`,
265
+ "X-Agent-ID": opts.agentId,
266
+ },
267
+ body: JSON.stringify(payload.body),
268
+ });
269
+ if (!resp.ok) {
270
+ let detail = "";
271
+ try {
272
+ detail = (await resp.text()).slice(0, 500);
273
+ } catch {
274
+ /* ignore body read failure */
275
+ }
276
+ console.warn(
277
+ scrubSecrets(
278
+ `[profile-sync] ${payload.label} sync failed: HTTP ${resp.status}${detail ? ` — ${detail}` : ""}`,
279
+ ),
280
+ );
281
+ }
282
+ } catch (err) {
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ console.warn(scrubSecrets(`[profile-sync] ${payload.label} sync errored: ${msg}`));
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Sync the agent's local profile files back to the API. Reads SOUL/IDENTITY/
290
+ * TOOLS/HEARTBEAT/CLAUDE.md + the agent-managed section of start-up.sh and
291
+ * POSTs each changed group. Idempotent server-side: the profile route only
292
+ * writes a new `context_versions` row when the content hash changes, so a
293
+ * redundant sync (pi extension + runner, or an unchanged file) is a no-op.
294
+ *
295
+ * Always resolves (never throws) — failures are logged, not propagated.
296
+ */
297
+ export async function syncProfileFilesToServer(opts: ProfileSyncOptions): Promise<void> {
298
+ const changeSource = opts.changeSource ?? "session_sync";
299
+ const fields = opts.fields ?? ["identity", "claude", "setup"];
300
+
301
+ const payloads = await collectProfilePayloads(
302
+ fields,
303
+ changeSource,
304
+ readFileIfExists,
305
+ opts.claudeMdPath ?? CLAUDE_MD_PATH,
306
+ );
307
+ for (const payload of payloads) {
308
+ await postProfileUpdate(opts, payload);
309
+ }
310
+ }
@@ -36,6 +36,7 @@ import { initTelemetry, telemetry } from "../telemetry.ts";
36
36
  import type { ProviderName, RepoGuidelines } from "../types.ts";
37
37
  import { getApiKey } from "../utils/api-key.ts";
38
38
  import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
39
+ import { getMcpBaseUrl } from "../utils/constants.ts";
39
40
  import { getContextWindowSize } from "../utils/context-window.ts";
40
41
  import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
41
42
  import {
@@ -52,6 +53,7 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
52
53
  import { interpolate } from "../workflows/template.ts";
53
54
  import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
54
55
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
56
+ import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts";
55
57
  import {
56
58
  buildCredStatusReport,
57
59
  buildLatestModelReport,
@@ -70,6 +72,34 @@ import "./templates.ts";
70
72
  /** Throttle interval for progress updates (3 seconds). */
71
73
  const PROGRESS_THROTTLE_MS = 3000;
72
74
 
75
+ /** Minimum spacing for explicit runner GC sweeps. */
76
+ const RUNNER_GC_MIN_INTERVAL_MS = 5 * 60 * 1000;
77
+
78
+ let lastRunnerGcAt = 0;
79
+
80
+ type GcCapableGlobal = typeof globalThis & { gc?: () => void };
81
+
82
+ function scheduleRunnerGc(reason: string): boolean {
83
+ const gc = (globalThis as GcCapableGlobal).gc;
84
+ if (typeof gc !== "function") return false;
85
+
86
+ const now = Date.now();
87
+ if (now - lastRunnerGcAt < RUNNER_GC_MIN_INTERVAL_MS) return false;
88
+ lastRunnerGcAt = now;
89
+
90
+ const timer = setTimeout(() => {
91
+ const startedAt = Date.now();
92
+ try {
93
+ gc();
94
+ console.log(`[runner] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
95
+ } catch (err) {
96
+ console.warn(`[runner] Explicit GC failed after ${reason}: ${err}`);
97
+ }
98
+ }, 0);
99
+ timer.unref?.();
100
+ return true;
101
+ }
102
+
73
103
  /** Save PM2 process list for persistence across container restarts */
74
104
  async function savePm2State(role: string): Promise<void> {
75
105
  try {
@@ -1424,6 +1454,21 @@ interface RunningTask {
1424
1454
  keySuffix: string;
1425
1455
  keyIndex: number;
1426
1456
  };
1457
+ /**
1458
+ * Harness provider this session was actually spawned/resumed on, snapshotted
1459
+ * at spawn time. The runner lets in-flight sessions finish on their original
1460
+ * adapter after a live provider swap, so the session-end profile sync must
1461
+ * decide based on THIS value — not the mutable global `state.harnessProvider`.
1462
+ */
1463
+ harnessProvider: ProviderName;
1464
+ /**
1465
+ * Whether this session ran in a local `/workspace` environment, snapshotted
1466
+ * from `adapter.traits.hasLocalEnvironment` at spawn time. Gates the
1467
+ * session-end FS → DB profile sync per finished session (a session that
1468
+ * started local must still sync even if the worker was swapped to a remote
1469
+ * provider before it completed, and vice versa).
1470
+ */
1471
+ hasLocalEnvironment: boolean;
1427
1472
  }
1428
1473
 
1429
1474
  /** Runner state for tracking concurrent tasks */
@@ -3010,6 +3055,7 @@ async function spawnProviderProcess(
3010
3055
  }
3011
3056
  closeActiveToolSpans(result.exitCode === 0 ? "ok" : "error", result.failureReason);
3012
3057
  sessionSpan.end();
3058
+ scheduleRunnerGc("session completion");
3013
3059
 
3014
3060
  return result;
3015
3061
  }),
@@ -3024,6 +3070,7 @@ async function spawnProviderProcess(
3024
3070
  });
3025
3071
  closeActiveToolSpans("error", error instanceof Error ? error.message : String(error));
3026
3072
  sessionSpan.end();
3073
+ scheduleRunnerGc("session error");
3027
3074
  throw error;
3028
3075
  }),
3029
3076
  );
@@ -3046,6 +3093,11 @@ async function spawnProviderProcess(
3046
3093
  promise,
3047
3094
  result: null,
3048
3095
  credentialInfo,
3096
+ // Snapshot the provider + local-env trait of the adapter this session is
3097
+ // spawned on, so the session-end sync decision survives a live provider
3098
+ // swap that mutates the global RunnerState (review finding 2).
3099
+ harnessProvider: opts.harnessProvider,
3100
+ hasLocalEnvironment: adapter.traits.hasLocalEnvironment,
3049
3101
  };
3050
3102
 
3051
3103
  // Non-blocking completion tracking
@@ -3073,6 +3125,8 @@ async function checkCompletedProcesses(
3073
3125
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
3074
3126
  workingDir?: string;
3075
3127
  credentialInfo?: RunningTask["credentialInfo"];
3128
+ harnessProvider: ProviderName;
3129
+ hasLocalEnvironment: boolean;
3076
3130
  }> = [];
3077
3131
 
3078
3132
  for (const [taskId, task] of state.activeTasks) {
@@ -3088,6 +3142,8 @@ async function checkCompletedProcesses(
3088
3142
  cursorUpdates: task.cursorUpdates,
3089
3143
  workingDir: task.workingDir,
3090
3144
  credentialInfo: task.credentialInfo,
3145
+ harnessProvider: task.harnessProvider,
3146
+ hasLocalEnvironment: task.hasLocalEnvironment,
3091
3147
  });
3092
3148
  }
3093
3149
  }
@@ -3217,6 +3273,40 @@ async function checkCompletedProcesses(
3217
3273
  }
3218
3274
  }
3219
3275
  }
3276
+
3277
+ // Harness-agnostic FS → DB profile sync at session end.
3278
+ //
3279
+ // The Claude plugin Stop hook and the pi extension sync SOUL/IDENTITY/TOOLS/
3280
+ // CLAUDE.md + start-up.sh on their own, but codex/opencode have no such path
3281
+ // and pi's can silently not-fire (2026-06-01 regression). Running the sync
3282
+ // here — at the single point where every completed harness session converges
3283
+ // (including crashes, since the process resolved with an exit code) — makes
3284
+ // persistence reliable for ALL local-environment harnesses without
3285
+ // per-adapter code. Idempotent: the profile route only writes a new context
3286
+ // version when the content hash changes, so pi's double-sync and claude's
3287
+ // redundant POST collapse to a no-op. NON-FATAL — never blocks completion;
3288
+ // failures are logged (scrubbed) inside the helper.
3289
+ //
3290
+ // The local-env gate is per FINISHED session, snapshotted at spawn time —
3291
+ // NOT the mutable global `state.hasLocalEnvironment`. The runner lets
3292
+ // in-flight sessions finish on their original adapter after a live provider
3293
+ // swap, so reading the global would (a) skip a session that started local
3294
+ // when the worker has since flipped to a remote provider, and (b) sync stale
3295
+ // local files after a remote session finishes once the worker flipped local.
3296
+ // We sync when ANY finished session in this batch ran locally, and pick the
3297
+ // CLAUDE.md source from those sessions' providers (review finding 2 + 1).
3298
+ const localCompleted = completedTasks.filter((t) => t.hasLocalEnvironment);
3299
+ if (apiConfig && localCompleted.length > 0) {
3300
+ await syncProfileFilesToServer({
3301
+ agentId: apiConfig.agentId,
3302
+ apiUrl: apiConfig.apiUrl,
3303
+ apiKey: apiConfig.apiKey,
3304
+ changeSource: "session_sync",
3305
+ claudeMdPath: resolveClaudeMdPath(localCompleted.map((t) => t.harnessProvider)),
3306
+ }).catch((err) => {
3307
+ console.warn(`[${role}] ${scrubSecrets(`Profile sync failed: ${err}`)}`);
3308
+ });
3309
+ }
3220
3310
  }
3221
3311
 
3222
3312
  const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -3299,7 +3389,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3299
3389
  // Get agent identity and swarm URL for base prompt
3300
3390
  const agentId = process.env.AGENT_ID || "unknown";
3301
3391
 
3302
- const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
3392
+ const apiUrl = getMcpBaseUrl();
3303
3393
  const swarmUrl = process.env.SWARM_URL || "localhost";
3304
3394
  const apiKey = getApiKey();
3305
3395