@desplega.ai/agent-swarm 1.89.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 (63) hide show
  1. package/README.md +4 -0
  2. package/openapi.json +74 -1
  3. package/package.json +6 -6
  4. package/plugin/skills/composio/SKILL.md +138 -63
  5. package/plugin/skills/composio-gmail/SKILL.md +83 -0
  6. package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
  7. package/plugin/skills/composio-google-docs/SKILL.md +71 -0
  8. package/src/artifact-sdk/server.ts +2 -1
  9. package/src/be/db.ts +28 -0
  10. package/src/be/memory/providers/sqlite-store.ts +6 -1
  11. package/src/be/memory/types.ts +1 -0
  12. package/src/be/modelsdev-cache.json +752 -81
  13. package/src/be/scripts/typecheck.ts +132 -1
  14. package/src/be/seed-scripts/catalog/compound-insights.ts +188 -0
  15. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  16. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  17. package/src/be/seed-scripts/catalog/tool-usage.ts +56 -0
  18. package/src/be/seed-scripts/index.ts +36 -0
  19. package/src/commands/artifact.ts +3 -2
  20. package/src/commands/profile-sync.ts +310 -0
  21. package/src/commands/runner.ts +91 -1
  22. package/src/heartbeat/heartbeat.ts +54 -7
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/index.ts +47 -0
  25. package/src/http/integrations.ts +6 -1
  26. package/src/http/mcp-bridge.ts +117 -0
  27. package/src/http/mcp-oauth.ts +97 -39
  28. package/src/http/memory.ts +5 -2
  29. package/src/http/openapi.ts +2 -2
  30. package/src/http/pages-public.ts +10 -11
  31. package/src/http/pages.ts +7 -11
  32. package/src/http/scripts.ts +24 -1
  33. package/src/http/tasks.ts +2 -0
  34. package/src/http/utils.ts +11 -4
  35. package/src/jira/app.ts +2 -3
  36. package/src/jira/webhook-lifecycle.ts +2 -1
  37. package/src/linear/app.ts +2 -3
  38. package/src/providers/claude-adapter.ts +26 -0
  39. package/src/scripts-runtime/executors/native.ts +1 -0
  40. package/src/scripts-runtime/sdk-allowlist.ts +121 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  42. package/src/scripts-runtime/types/stdlib.d.ts +227 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +227 -0
  44. package/src/tasks/worker-follow-up.ts +19 -1
  45. package/src/tests/claude-adapter-otel.test.ts +85 -1
  46. package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
  47. package/src/tests/hook-registration-nudge.test.ts +69 -0
  48. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  49. package/src/tests/pages-public-html.test.ts +41 -0
  50. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  51. package/src/tests/profile-sync.test.ts +282 -0
  52. package/src/tests/scripts-runtime.test.ts +33 -0
  53. package/src/tests/seed-scripts.test.ts +2 -2
  54. package/src/tools/create-metric.ts +2 -3
  55. package/src/tools/create-page.ts +3 -6
  56. package/src/tools/memory-rate.ts +2 -1
  57. package/src/tools/memory-search.ts +1 -0
  58. package/src/tools/register-kapso-number.ts +2 -4
  59. package/src/tools/request-human-input.ts +2 -1
  60. package/src/tools/script-common.ts +2 -4
  61. package/src/tools/script-run.ts +7 -0
  62. package/src/utils/constants.ts +58 -8
  63. package/templates/skills/swarm-scripts/content.md +46 -7
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  assignUnassignedTaskPending,
3
+ backfillSupersedeTaskResumeTaskId,
3
4
  cleanupStaleSessions,
4
5
  createTaskExtended,
5
6
  deleteActiveSession,
@@ -25,7 +26,7 @@ import {
25
26
  updateAgentStatus,
26
27
  } from "../be/db";
27
28
  import { resolveTemplate } from "../prompts/resolver";
28
- import { createResumeFollowUp } from "../tasks/worker-follow-up";
29
+ import { createResumeFollowUp, getNextResumeGeneration } from "../tasks/worker-follow-up";
29
30
  import type { AgentTask } from "../types";
30
31
  import { getExecutorRegistry } from "../workflows";
31
32
  import { recoverIncompleteRuns } from "../workflows/recovery";
@@ -60,6 +61,11 @@ const STALE_CLEANUP_THRESHOLD_MINUTES = Number(process.env.HEARTBEAT_STALE_CLEAN
60
61
  /** Max pool tasks to auto-assign per sweep */
61
62
  const MAX_AUTO_ASSIGN_PER_SWEEP = Number(process.env.HEARTBEAT_MAX_AUTO_ASSIGN) || 5;
62
63
 
64
+ /** Max crash-recovery resume generations before failing for lead triage */
65
+ export const MAX_RESUME_GENERATIONS = Number(process.env.HEARTBEAT_MAX_RESUME_GENERATIONS) || 3;
66
+
67
+ export const RESUME_BUDGET_EXHAUSTED_REASON = "resume_budget_exhausted";
68
+
63
69
  /** Heartbeat checklist interval: how often to check HEARTBEAT.md (default: 30 min) */
64
70
  const HEARTBEAT_CHECKLIST_INTERVAL_MS =
65
71
  Number(process.env.HEARTBEAT_CHECKLIST_INTERVAL_MS) || 30 * 60 * 1000;
@@ -98,10 +104,17 @@ export interface HeartbeatFindings {
98
104
  let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
99
105
  let checklistInterval: ReturnType<typeof setInterval> | null = null;
100
106
  let isSweeping = false;
107
+ let beforeHeartbeatSupersedeForTests: ((task: AgentTask) => void) | null = null;
101
108
 
102
109
  /** Tasks auto-failed during the reboot sweep, consumed by boot triage */
103
110
  let rebootAffectedTasks: Array<{ original: AgentTask; retryTaskId: string | null }> = [];
104
111
 
112
+ export function setBeforeHeartbeatSupersedeForTests(
113
+ hook: ((task: AgentTask) => void) | null,
114
+ ): void {
115
+ beforeHeartbeatSupersedeForTests = hook;
116
+ }
117
+
105
118
  // ============================================================================
106
119
  // Tier 1: Preflight Gate
107
120
  // ============================================================================
@@ -300,16 +313,40 @@ function remediateCrashedWorkerTask(
300
313
  return;
301
314
  }
302
315
 
303
- // Supersede + resume path.
316
+ const nextResumeGeneration = getNextResumeGeneration(task);
317
+ if (nextResumeGeneration > MAX_RESUME_GENERATIONS) {
318
+ const failed = failTask(task.id, RESUME_BUDGET_EXHAUSTED_REASON);
319
+ if (failed) {
320
+ findings.autoFailedTasks.push({
321
+ taskId: task.id,
322
+ agentId: task.agentId,
323
+ reason: RESUME_BUDGET_EXHAUSTED_REASON,
324
+ });
325
+ if (opts.cleanupActiveSession) deleteActiveSession(task.id);
326
+ console.warn(
327
+ `[Heartbeat] Auto-failed task ${task.id.slice(0, 8)} — ${RESUME_BUDGET_EXHAUSTED_REASON} (${opts.shortLabel})`,
328
+ );
329
+ const remaining = getActiveTaskCount(task.agentId);
330
+ if (remaining === 0) updateAgentStatus(task.agentId, "idle");
331
+ }
332
+ return;
333
+ }
334
+
335
+ beforeHeartbeatSupersedeForTests?.(task);
336
+
304
337
  const superseded = supersedeTask(task.id, {
305
338
  reason: opts.supersedeReason,
306
339
  resumeTaskId: null,
307
340
  });
308
- if (!superseded) return;
341
+ if (!superseded) {
342
+ return;
343
+ }
309
344
 
310
345
  const resume = createResumeFollowUp({ parentId: task.id, reason: "crash_recovery" });
311
346
 
312
347
  if (resume.kind === "created") {
348
+ backfillSupersedeTaskResumeTaskId(task.id, resume.task.id);
349
+
313
350
  findings.autoResumedTasks.push({
314
351
  taskId: task.id,
315
352
  resumeTaskId: resume.task.id,
@@ -320,10 +357,20 @@ function remediateCrashedWorkerTask(
320
357
  `[Heartbeat] Auto-superseded task ${task.id.slice(0, 8)} — created resume ${resume.task.id.slice(0, 8)} (${opts.shortLabel})`,
321
358
  );
322
359
  } else {
323
- // `workflow-skip` is unreachable here (handled above). `skipped` covers
324
- // parent-not-found / lead-not-found edge cases — just log for operators.
325
- console.log(
326
- `[Heartbeat] Task ${task.id.slice(0, 8)} superseded but no resume created (${
360
+ const reason =
361
+ resume.kind === "skipped"
362
+ ? `resume_creation_skipped_${resume.reason}`
363
+ : "resume_creation_skipped_workflow";
364
+ const failed = failTask(task.id, reason);
365
+ if (failed) {
366
+ findings.autoFailedTasks.push({
367
+ taskId: task.id,
368
+ agentId: task.agentId,
369
+ reason,
370
+ });
371
+ }
372
+ console.warn(
373
+ `[Heartbeat] Task ${task.id.slice(0, 8)} failed because no resume was created (${
327
374
  resume.kind === "skipped" ? resume.reason : "workflow-skip"
328
375
  })`,
329
376
  );
package/src/hooks/hook.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from "../be/memory/raters/llm";
12
12
  import type { Agent } from "../types";
13
13
  import { getApiKey } from "../utils/api-key";
14
+ import { getMcpBaseUrl } from "../utils/constants";
14
15
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
15
16
  import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
16
17
 
@@ -82,6 +83,27 @@ interface CancelledTasksResponse {
82
83
  cancelled: CancelledTask[];
83
84
  }
84
85
 
86
+ /**
87
+ * Decide whether to show the "not registered — use join-swarm" nudge.
88
+ *
89
+ * Rules:
90
+ * 1. Only nudge on SessionStart — other events should not prompt re-registration.
91
+ * 2. If X-Agent-ID header is present the agent is pre-assigned; a null lookup
92
+ * is transient, not a real "unregistered" state → suppress the nudge.
93
+ * 3. Only genuinely-unregistered agents (no X-Agent-ID, null lookup, SessionStart)
94
+ * see the nudge.
95
+ */
96
+ export function shouldShowRegistrationNudge(opts: {
97
+ agentInfoPresent: boolean;
98
+ eventType: string;
99
+ hasAgentIdHeader: boolean;
100
+ }): boolean {
101
+ if (opts.agentInfoPresent) return false;
102
+ if (opts.eventType !== "SessionStart") return false;
103
+ if (opts.hasAgentIdHeader) return false;
104
+ return true;
105
+ }
106
+
85
107
  /**
86
108
  * Check if a path is under the agent's own subdirectory on the shared disk.
87
109
  * Shared disk categories: thoughts, memory, downloads, misc.
@@ -150,7 +172,7 @@ async function readTaskFile(): Promise<TaskFileData | null> {
150
172
  async function fetchTaskDetails(
151
173
  taskId: string,
152
174
  ): Promise<{ id: string; task: string; progress?: string } | null> {
153
- const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
175
+ const apiUrl = getMcpBaseUrl();
154
176
  const apiKey = getApiKey();
155
177
  const headers: Record<string, string> = {};
156
178
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
@@ -889,13 +911,15 @@ export async function handleHook(): Promise<void> {
889
911
  console.log(tray);
890
912
  }
891
913
  }
892
- } else {
914
+ } else if (
915
+ shouldShowRegistrationNudge({
916
+ agentInfoPresent: false,
917
+ eventType: msg.hook_event_name,
918
+ hasAgentIdHeader: hasAgentIdHeader(),
919
+ })
920
+ ) {
893
921
  console.log(
894
- `You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info.
895
-
896
- If the ${SERVER_NAME} server is not running or disabled, disregard this message.
897
-
898
- ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?.headers["X-Agent-ID"]}, it will be used automatically on join-swarm.` : "You do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm."}`,
922
+ `You are not registered in the agent swarm yet. Use the join-swarm tool to register yourself, then check your status with my-agent-info.\n\nIf the ${SERVER_NAME} server is not running or disabled, disregard this message.\n\nYou do not have a pre-defined agent ID, you will receive one when you join the swarm, or optionally you can request one when calling join-swarm.`,
899
923
  );
900
924
  }
901
925
 
@@ -1151,8 +1175,7 @@ ${hasAgentIdHeader() ? `You have a pre-defined agent ID via header: ${mcpConfig?
1151
1175
  editedPath.startsWith("/workspace/shared/memory/"))
1152
1176
  ) {
1153
1177
  try {
1154
- const apiUrl =
1155
- process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
1178
+ const apiUrl = getMcpBaseUrl();
1156
1179
  const apiKey = getApiKey();
1157
1180
  const fileContent = await Bun.file(editedPath).text();
1158
1181
  const isShared = editedPath.startsWith("/workspace/shared/");