@desplega.ai/agent-swarm 1.90.0 → 1.92.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 (96) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +803 -150
  3. package/package.json +5 -5
  4. package/src/artifact-sdk/server.ts +2 -1
  5. package/src/be/db.ts +337 -1
  6. package/src/be/memory/providers/sqlite-store.ts +6 -1
  7. package/src/be/memory/types.ts +1 -0
  8. package/src/be/migrations/083_script_workflows.sql +51 -0
  9. package/src/be/modelsdev-cache.json +42352 -38595
  10. package/src/be/scripts/typecheck.ts +181 -1
  11. package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
  12. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
  13. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  14. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  15. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  16. package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
  17. package/src/be/seed-scripts/index.ts +54 -0
  18. package/src/be/seed-skills/index.ts +7 -0
  19. package/src/be/swarm-config-guard.ts +17 -0
  20. package/src/commands/artifact.ts +3 -2
  21. package/src/commands/profile-sync.ts +310 -0
  22. package/src/commands/runner.ts +134 -3
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/db-query.ts +20 -5
  25. package/src/http/index.ts +57 -0
  26. package/src/http/integrations.ts +6 -1
  27. package/src/http/mcp-bridge.ts +117 -0
  28. package/src/http/mcp-oauth.ts +97 -39
  29. package/src/http/memory.ts +5 -2
  30. package/src/http/openapi.ts +2 -2
  31. package/src/http/pages-public.ts +10 -11
  32. package/src/http/pages.ts +7 -11
  33. package/src/http/script-runs.ts +555 -0
  34. package/src/http/scripts.ts +24 -1
  35. package/src/http/utils.ts +11 -4
  36. package/src/jira/app.ts +2 -3
  37. package/src/jira/webhook-lifecycle.ts +2 -1
  38. package/src/linear/app.ts +2 -3
  39. package/src/prompts/session-templates.ts +24 -4
  40. package/src/providers/claude-adapter.ts +86 -13
  41. package/src/script-workflows/executor.ts +110 -0
  42. package/src/script-workflows/harness.ts +73 -0
  43. package/src/script-workflows/label-lint.ts +51 -0
  44. package/src/script-workflows/limits.ts +22 -0
  45. package/src/script-workflows/supervisor.ts +139 -0
  46. package/src/script-workflows/workflow-ctx.ts +205 -0
  47. package/src/scripts-runtime/executors/native.ts +1 -0
  48. package/src/scripts-runtime/sdk-allowlist.ts +124 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  50. package/src/scripts-runtime/types/stdlib.d.ts +287 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
  52. package/src/server.ts +2 -0
  53. package/src/slack/handlers.ts +11 -4
  54. package/src/slack/message-text.ts +98 -0
  55. package/src/slack/thread-buffer.ts +5 -3
  56. package/src/tests/claude-adapter-binary.test.ts +147 -4
  57. package/src/tests/claude-adapter-otel.test.ts +85 -1
  58. package/src/tests/db-query.test.ts +28 -0
  59. package/src/tests/error-tracker.test.ts +121 -0
  60. package/src/tests/harness-provider-resolution.test.ts +33 -0
  61. package/src/tests/hook-registration-nudge.test.ts +69 -0
  62. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  63. package/src/tests/mcp-tools.test.ts +6 -0
  64. package/src/tests/pages-public-html.test.ts +41 -0
  65. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  66. package/src/tests/profile-sync.test.ts +282 -0
  67. package/src/tests/prompt-template-session.test.ts +34 -5
  68. package/src/tests/script-runs-http.test.ts +278 -0
  69. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  70. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  71. package/src/tests/scripts-mcp-e2e.test.ts +49 -2
  72. package/src/tests/scripts-runtime.test.ts +33 -0
  73. package/src/tests/seed-scripts.test.ts +347 -2
  74. package/src/tests/slack-message-text.test.ts +250 -0
  75. package/src/tests/system-default-skills.test.ts +40 -0
  76. package/src/tools/create-metric.ts +2 -3
  77. package/src/tools/create-page.ts +3 -6
  78. package/src/tools/db-query.ts +16 -6
  79. package/src/tools/memory-rate.ts +2 -1
  80. package/src/tools/memory-search.ts +1 -0
  81. package/src/tools/register-kapso-number.ts +2 -4
  82. package/src/tools/request-human-input.ts +2 -1
  83. package/src/tools/script-common.ts +2 -4
  84. package/src/tools/script-run.ts +7 -0
  85. package/src/tools/script-runs.ts +123 -0
  86. package/src/tools/slack-read.ts +12 -3
  87. package/src/tools/tool-config.ts +4 -1
  88. package/src/types.ts +52 -0
  89. package/src/utils/constants.ts +58 -8
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/skills/pages/content.md +205 -55
  94. package/templates/skills/script-workflows/config.json +14 -0
  95. package/templates/skills/script-workflows/content.md +68 -0
  96. package/templates/skills/swarm-scripts/content.md +45 -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,12 +36,15 @@ 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 {
43
+ isCodexCreditsExhaustedMessage,
42
44
  isRateLimitMessage,
43
45
  MAX_RATE_LIMIT_RESET_MS,
44
46
  parseRateLimitResetTime,
47
+ resolveCodexCreditsExhaustedCooldownMs,
45
48
  } from "../utils/error-tracker.ts";
46
49
  import { resolveHarnessProvider } from "../utils/harness-provider.ts";
47
50
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
@@ -52,6 +55,7 @@ import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
52
55
  import { interpolate } from "../workflows/template.ts";
53
56
  import { buildContextPreamble, buildResumeContextPreamble } from "./context-preamble.ts";
54
57
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
58
+ import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts";
55
59
  import {
56
60
  buildCredStatusReport,
57
61
  buildLatestModelReport,
@@ -70,6 +74,34 @@ import "./templates.ts";
70
74
  /** Throttle interval for progress updates (3 seconds). */
71
75
  const PROGRESS_THROTTLE_MS = 3000;
72
76
 
77
+ /** Minimum spacing for explicit runner GC sweeps. */
78
+ const RUNNER_GC_MIN_INTERVAL_MS = 5 * 60 * 1000;
79
+
80
+ let lastRunnerGcAt = 0;
81
+
82
+ type GcCapableGlobal = typeof globalThis & { gc?: () => void };
83
+
84
+ function scheduleRunnerGc(reason: string): boolean {
85
+ const gc = (globalThis as GcCapableGlobal).gc;
86
+ if (typeof gc !== "function") return false;
87
+
88
+ const now = Date.now();
89
+ if (now - lastRunnerGcAt < RUNNER_GC_MIN_INTERVAL_MS) return false;
90
+ lastRunnerGcAt = now;
91
+
92
+ const timer = setTimeout(() => {
93
+ const startedAt = Date.now();
94
+ try {
95
+ gc();
96
+ console.log(`[runner] Explicit GC completed after ${reason} in ${Date.now() - startedAt}ms`);
97
+ } catch (err) {
98
+ console.warn(`[runner] Explicit GC failed after ${reason}: ${err}`);
99
+ }
100
+ }, 0);
101
+ timer.unref?.();
102
+ return true;
103
+ }
104
+
73
105
  /** Save PM2 process list for persistence across container restarts */
74
106
  async function savePm2State(role: string): Promise<void> {
75
107
  try {
@@ -394,6 +426,7 @@ async function fetchResolvedEnv(
394
426
  const RELOADABLE_ENV_KEYS: ReadonlySet<string> = new Set([
395
427
  "MODEL_OVERRIDE",
396
428
  "AGENT_FS_SHARED_ORG_ID",
429
+ "SWARM_USE_CLAUDE_BRIDGE",
397
430
  ]);
398
431
 
399
432
  /**
@@ -1424,6 +1457,21 @@ interface RunningTask {
1424
1457
  keySuffix: string;
1425
1458
  keyIndex: number;
1426
1459
  };
1460
+ /**
1461
+ * Harness provider this session was actually spawned/resumed on, snapshotted
1462
+ * at spawn time. The runner lets in-flight sessions finish on their original
1463
+ * adapter after a live provider swap, so the session-end profile sync must
1464
+ * decide based on THIS value — not the mutable global `state.harnessProvider`.
1465
+ */
1466
+ harnessProvider: ProviderName;
1467
+ /**
1468
+ * Whether this session ran in a local `/workspace` environment, snapshotted
1469
+ * from `adapter.traits.hasLocalEnvironment` at spawn time. Gates the
1470
+ * session-end FS → DB profile sync per finished session (a session that
1471
+ * started local must still sync even if the worker was swapped to a remote
1472
+ * provider before it completed, and vice versa).
1473
+ */
1474
+ hasLocalEnvironment: boolean;
1427
1475
  }
1428
1476
 
1429
1477
  /** Runner state for tracking concurrent tasks */
@@ -1438,6 +1486,13 @@ interface RunnerState {
1438
1486
  * (per-task live re-resolution) will mutate this between tasks.
1439
1487
  */
1440
1488
  harnessProvider: ProviderName;
1489
+ /**
1490
+ * Effective Codex credits-exhausted cooldown (ms), resolved from `swarm_config`
1491
+ * (key `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`) > default 2h constant, clamped to
1492
+ * [5m, 7d]. Reconciled live by `applySwarmConfigDrift` — read at the cooldown
1493
+ * application site so a fresh value applies to the next credits-exhausted failure.
1494
+ */
1495
+ codexCreditsExhaustedCooldownMs: number;
1441
1496
  }
1442
1497
 
1443
1498
  /** Buffer for session logs */
@@ -3010,6 +3065,7 @@ async function spawnProviderProcess(
3010
3065
  }
3011
3066
  closeActiveToolSpans(result.exitCode === 0 ? "ok" : "error", result.failureReason);
3012
3067
  sessionSpan.end();
3068
+ scheduleRunnerGc("session completion");
3013
3069
 
3014
3070
  return result;
3015
3071
  }),
@@ -3024,6 +3080,7 @@ async function spawnProviderProcess(
3024
3080
  });
3025
3081
  closeActiveToolSpans("error", error instanceof Error ? error.message : String(error));
3026
3082
  sessionSpan.end();
3083
+ scheduleRunnerGc("session error");
3027
3084
  throw error;
3028
3085
  }),
3029
3086
  );
@@ -3046,6 +3103,11 @@ async function spawnProviderProcess(
3046
3103
  promise,
3047
3104
  result: null,
3048
3105
  credentialInfo,
3106
+ // Snapshot the provider + local-env trait of the adapter this session is
3107
+ // spawned on, so the session-end sync decision survives a live provider
3108
+ // swap that mutates the global RunnerState (review finding 2).
3109
+ harnessProvider: opts.harnessProvider,
3110
+ hasLocalEnvironment: adapter.traits.hasLocalEnvironment,
3049
3111
  };
3050
3112
 
3051
3113
  // Non-blocking completion tracking
@@ -3073,6 +3135,8 @@ async function checkCompletedProcesses(
3073
3135
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
3074
3136
  workingDir?: string;
3075
3137
  credentialInfo?: RunningTask["credentialInfo"];
3138
+ harnessProvider: ProviderName;
3139
+ hasLocalEnvironment: boolean;
3076
3140
  }> = [];
3077
3141
 
3078
3142
  for (const [taskId, task] of state.activeTasks) {
@@ -3088,6 +3152,8 @@ async function checkCompletedProcesses(
3088
3152
  cursorUpdates: task.cursorUpdates,
3089
3153
  workingDir: task.workingDir,
3090
3154
  credentialInfo: task.credentialInfo,
3155
+ harnessProvider: task.harnessProvider,
3156
+ hasLocalEnvironment: task.hasLocalEnvironment,
3091
3157
  });
3092
3158
  }
3093
3159
  }
@@ -3153,6 +3219,12 @@ async function checkCompletedProcesses(
3153
3219
  console.log(
3154
3220
  `[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
3155
3221
  );
3222
+ } else if (isCodexCreditsExhaustedMessage(failureReason)) {
3223
+ const cooldownMs = state.codexCreditsExhaustedCooldownMs;
3224
+ rateLimitedUntil = new Date(Date.now() + cooldownMs).toISOString();
3225
+ console.log(
3226
+ `[credentials] Codex credits exhausted — applying cooldown (${cooldownMs}ms): ${rateLimitedUntil}`,
3227
+ );
3156
3228
  } else {
3157
3229
  rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
3158
3230
  }
@@ -3217,6 +3289,40 @@ async function checkCompletedProcesses(
3217
3289
  }
3218
3290
  }
3219
3291
  }
3292
+
3293
+ // Harness-agnostic FS → DB profile sync at session end.
3294
+ //
3295
+ // The Claude plugin Stop hook and the pi extension sync SOUL/IDENTITY/TOOLS/
3296
+ // CLAUDE.md + start-up.sh on their own, but codex/opencode have no such path
3297
+ // and pi's can silently not-fire (2026-06-01 regression). Running the sync
3298
+ // here — at the single point where every completed harness session converges
3299
+ // (including crashes, since the process resolved with an exit code) — makes
3300
+ // persistence reliable for ALL local-environment harnesses without
3301
+ // per-adapter code. Idempotent: the profile route only writes a new context
3302
+ // version when the content hash changes, so pi's double-sync and claude's
3303
+ // redundant POST collapse to a no-op. NON-FATAL — never blocks completion;
3304
+ // failures are logged (scrubbed) inside the helper.
3305
+ //
3306
+ // The local-env gate is per FINISHED session, snapshotted at spawn time —
3307
+ // NOT the mutable global `state.hasLocalEnvironment`. The runner lets
3308
+ // in-flight sessions finish on their original adapter after a live provider
3309
+ // swap, so reading the global would (a) skip a session that started local
3310
+ // when the worker has since flipped to a remote provider, and (b) sync stale
3311
+ // local files after a remote session finishes once the worker flipped local.
3312
+ // We sync when ANY finished session in this batch ran locally, and pick the
3313
+ // CLAUDE.md source from those sessions' providers (review finding 2 + 1).
3314
+ const localCompleted = completedTasks.filter((t) => t.hasLocalEnvironment);
3315
+ if (apiConfig && localCompleted.length > 0) {
3316
+ await syncProfileFilesToServer({
3317
+ agentId: apiConfig.agentId,
3318
+ apiUrl: apiConfig.apiUrl,
3319
+ apiKey: apiConfig.apiKey,
3320
+ changeSource: "session_sync",
3321
+ claudeMdPath: resolveClaudeMdPath(localCompleted.map((t) => t.harnessProvider)),
3322
+ }).catch((err) => {
3323
+ console.warn(`[${role}] ${scrubSecrets(`Profile sync failed: ${err}`)}`);
3324
+ });
3325
+ }
3220
3326
  }
3221
3327
 
3222
3328
  const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -3299,7 +3405,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3299
3405
  // Get agent identity and swarm URL for base prompt
3300
3406
  const agentId = process.env.AGENT_ID || "unknown";
3301
3407
 
3302
- const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
3408
+ const apiUrl = getMcpBaseUrl();
3303
3409
  const swarmUrl = process.env.SWARM_URL || "localhost";
3304
3410
  const apiKey = getApiKey();
3305
3411
 
@@ -3313,10 +3419,22 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3313
3419
  // Failures (network, API down, malformed value) fall back to env then "claude"
3314
3420
  // so a swarm_config outage cannot wedge boot.
3315
3421
  let bootProvider: ProviderName;
3422
+ // Codex credits-exhausted cooldown is sourced solely from the global swarm_config
3423
+ // (key `CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS`). Initialize to the default constant
3424
+ // for the case where it is unset, then apply the swarm_config value below; on a
3425
+ // boot-fetch failure it stays at the default. Reconciled live thereafter by
3426
+ // `applySwarmConfigDrift`.
3427
+ let bootCooldownMs = resolveCodexCreditsExhaustedCooldownMs(undefined);
3316
3428
  try {
3317
- bootProvider = (await fetchResolvedEnv(apiUrl, apiKey, agentId)).resolvedProvider;
3429
+ const bootEnv = await fetchResolvedEnv(apiUrl, apiKey, agentId);
3430
+ bootProvider = bootEnv.resolvedProvider;
3431
+ bootCooldownMs = resolveCodexCreditsExhaustedCooldownMs(
3432
+ bootEnv.env.CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
3433
+ );
3318
3434
  } catch (err) {
3319
- console.warn(`[runner] fetchResolvedEnv failed at boot, falling back to env: ${err}`);
3435
+ console.warn(
3436
+ `[runner] fetchResolvedEnv failed at boot, falling back to env for provider and the default cooldown: ${err}`,
3437
+ );
3320
3438
  bootProvider = resolveHarnessProvider({}, process.env);
3321
3439
  }
3322
3440
  console.log(`[runner] Resolved HARNESS_PROVIDER: ${bootProvider}`);
@@ -3520,6 +3638,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3520
3638
  activeTasks: new Map(),
3521
3639
  maxConcurrent,
3522
3640
  harnessProvider: bootProvider,
3641
+ codexCreditsExhaustedCooldownMs: bootCooldownMs,
3523
3642
  };
3524
3643
 
3525
3644
  // Track tasks already signaled for cancellation to avoid repeated SIGTERM
@@ -3600,6 +3719,18 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3600
3719
  agentVisibleChanged = true;
3601
3720
  }
3602
3721
 
3722
+ // (2b) Codex credits-exhausted cooldown — operator-tunable live. Not
3723
+ // agent-visible (doesn't change provider/maxConcurrent → no re-register).
3724
+ const nextCooldown = resolveCodexCreditsExhaustedCooldownMs(
3725
+ freshEnv.CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS,
3726
+ );
3727
+ if (nextCooldown !== state.codexCreditsExhaustedCooldownMs) {
3728
+ console.log(
3729
+ `[${role}] [config] codexCreditsExhaustedCooldownMs: ${state.codexCreditsExhaustedCooldownMs} → ${nextCooldown}`,
3730
+ );
3731
+ state.codexCreditsExhaustedCooldownMs = nextCooldown;
3732
+ }
3733
+
3603
3734
  // (3) Apply the small allowlist of safe-to-mutate env keys to process.env.
3604
3735
  const changedKeys = applyResolvedEnvToProcessEnv(freshEnv);
3605
3736
  if (changedKeys.length > 0) {
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/");
@@ -11,6 +11,24 @@ export interface DbQueryResult {
11
11
  total: number;
12
12
  }
13
13
 
14
+ export const DbQueryInputShape = {
15
+ sql: z.string().min(1).max(10_000).optional(),
16
+ query: z.string().min(1).max(10_000).optional().describe("Deprecated runtime alias for sql."),
17
+ params: z.array(z.any()).optional().default([]),
18
+ };
19
+
20
+ export const DbQueryInputSchema = z
21
+ .object(DbQueryInputShape)
22
+ .refine((body) => body.sql !== undefined || body.query !== undefined, {
23
+ message: "Either sql or query is required",
24
+ });
25
+
26
+ export type DbQueryInput = z.infer<typeof DbQueryInputSchema>;
27
+
28
+ export function resolveDbQuerySql(input: Pick<DbQueryInput, "sql" | "query">): string {
29
+ return input.sql ?? input.query ?? "";
30
+ }
31
+
14
32
  function stripTrailingSemicolon(sql: string): string {
15
33
  return sql.trim().replace(/;\s*$/, "").trim();
16
34
  }
@@ -67,10 +85,7 @@ const dbQueryRoute = route({
67
85
  pattern: ["api", "db-query"],
68
86
  summary: "Execute a read-only SQL query",
69
87
  tags: ["Debug"],
70
- body: z.object({
71
- sql: z.string().min(1).max(10_000),
72
- params: z.array(z.any()).optional().default([]),
73
- }),
88
+ body: DbQueryInputSchema,
74
89
  responses: {
75
90
  200: {
76
91
  description: "Query results",
@@ -100,7 +115,7 @@ export async function handleDbQuery(
100
115
  if (!parsed) return true;
101
116
 
102
117
  try {
103
- const result = executeReadOnlyQuery(parsed.body.sql, parsed.body.params);
118
+ const result = executeReadOnlyQuery(resolveDbQuerySql(parsed.body), parsed.body.params);
104
119
  json(res, result);
105
120
  } catch (err: unknown) {
106
121
  const message = err instanceof Error ? err.message : String(err);