@desplega.ai/agent-swarm 1.92.2 → 1.94.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 (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -0,0 +1,140 @@
1
+ import { z } from "zod";
2
+ import type { ProviderName } from "./types";
3
+
4
+ export const ModelTierSchema = z.enum(["smol", "regular", "smart", "ultra"]);
5
+ export type ModelTier = z.infer<typeof ModelTierSchema>;
6
+
7
+ export const MODEL_TIERS = ModelTierSchema.options;
8
+
9
+ export const LEGACY_MODEL_TO_TIER: Record<string, ModelTier> = {
10
+ haiku: "smol",
11
+ sonnet: "regular",
12
+ opus: "smart",
13
+ fable: "ultra",
14
+ };
15
+
16
+ export const MODEL_TIER_LABELS: Record<ModelTier, string> = {
17
+ smol: "Smol",
18
+ regular: "Regular",
19
+ smart: "Smart",
20
+ ultra: "Ultra",
21
+ };
22
+
23
+ export const DEFAULT_MODEL_TIER_MAP: Record<ProviderName, Record<ModelTier, string>> = {
24
+ claude: {
25
+ smol: "haiku",
26
+ regular: "sonnet",
27
+ smart: "opus",
28
+ ultra: "fable",
29
+ },
30
+ "claude-managed": {
31
+ smol: "claude-haiku-4-5",
32
+ regular: "claude-sonnet-4-6",
33
+ smart: "claude-opus-4-8",
34
+ ultra: "claude-fable-5",
35
+ },
36
+ codex: {
37
+ smol: "gpt-5.4-mini",
38
+ regular: "gpt-5.4",
39
+ smart: "gpt-5.5",
40
+ ultra: "gpt-5.5",
41
+ },
42
+ pi: {
43
+ smol: "openrouter/deepseek/deepseek-v4-flash",
44
+ regular: "openrouter/deepseek/deepseek-v4-flash",
45
+ smart: "openrouter/deepseek/deepseek-v4-pro",
46
+ ultra: "openrouter/anthropic/claude-opus-4.8",
47
+ },
48
+ opencode: {
49
+ smol: "openrouter/deepseek/deepseek-v4-flash",
50
+ regular: "openrouter/deepseek/deepseek-v4-flash",
51
+ smart: "openrouter/deepseek/deepseek-v4-pro",
52
+ ultra: "openrouter/anthropic/claude-opus-4.8",
53
+ },
54
+ devin: {
55
+ smol: "devin",
56
+ regular: "devin",
57
+ smart: "devin",
58
+ ultra: "devin",
59
+ },
60
+ };
61
+
62
+ export function parseModelTier(value: string | null | undefined): ModelTier | undefined {
63
+ if (!value) return undefined;
64
+ const normalized = value.trim().toLowerCase();
65
+ return ModelTierSchema.safeParse(normalized).success
66
+ ? (normalized as ModelTier)
67
+ : LEGACY_MODEL_TO_TIER[normalized];
68
+ }
69
+
70
+ export function splitLegacyModelAlias(input: {
71
+ model?: string | null;
72
+ modelTier?: string | null;
73
+ }): { model?: string; modelTier?: ModelTier } {
74
+ const explicitTier = parseModelTier(input.modelTier);
75
+ const model = input.model?.trim();
76
+ if (!model) return { modelTier: explicitTier };
77
+
78
+ const legacyTier = parseModelTier(model);
79
+ if (legacyTier && !explicitTier) {
80
+ return { modelTier: legacyTier };
81
+ }
82
+
83
+ return {
84
+ model,
85
+ modelTier: explicitTier,
86
+ };
87
+ }
88
+
89
+ function parseTierMapJson(value: string | undefined): Partial<Record<ModelTier, string>> {
90
+ if (!value) return {};
91
+ try {
92
+ const parsed = JSON.parse(value) as unknown;
93
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
94
+ const result: Partial<Record<ModelTier, string>> = {};
95
+ for (const tier of MODEL_TIERS) {
96
+ const model = (parsed as Record<string, unknown>)[tier];
97
+ if (typeof model === "string" && model.trim()) result[tier] = model.trim();
98
+ }
99
+ return result;
100
+ } catch {
101
+ return {};
102
+ }
103
+ }
104
+
105
+ export function resolveModelTier(opts: {
106
+ tier?: string | null;
107
+ harnessProvider: ProviderName;
108
+ env?: Record<string, string | undefined>;
109
+ }): string | undefined {
110
+ const tier = parseModelTier(opts.tier);
111
+ if (!tier) return undefined;
112
+
113
+ const env = opts.env ?? {};
114
+ const jsonOverrides = parseTierMapJson(env.MODEL_TIER_MAP);
115
+ const envKey = `MODEL_TIER_${tier.toUpperCase()}`;
116
+ const directOverride = env[envKey]?.trim();
117
+ if (directOverride) return directOverride;
118
+ if (jsonOverrides[tier]) return jsonOverrides[tier];
119
+
120
+ return DEFAULT_MODEL_TIER_MAP[opts.harnessProvider]?.[tier];
121
+ }
122
+
123
+ export function resolveTaskModelSelection(opts: {
124
+ model?: string | null;
125
+ modelTier?: string | null;
126
+ harnessProvider: ProviderName;
127
+ env?: Record<string, string | undefined>;
128
+ }): { model?: string; source: "model" | "modelTier" | "none" } {
129
+ const model = opts.model?.trim();
130
+ if (model) return { model, source: "model" };
131
+
132
+ const tierModel = resolveModelTier({
133
+ tier: opts.modelTier,
134
+ harnessProvider: opts.harnessProvider,
135
+ env: opts.env,
136
+ });
137
+ if (tierModel) return { model: tierModel, source: "modelTier" };
138
+
139
+ return { source: "none" };
140
+ }
@@ -470,6 +470,8 @@ class ClaudeSession implements ProviderSession {
470
470
  private sessionMcpConfig: string | null = null,
471
471
  private claudeBinaryArgv: readonly string[] = ["claude"],
472
472
  systemPromptFile: string | null = null,
473
+ private harnessVariant?: string,
474
+ private harnessVariantMeta?: Record<string, unknown>,
473
475
  ) {
474
476
  this.taskFilePid = taskFilePid;
475
477
  this.contextWindowSize = getContextWindowSize(model);
@@ -682,7 +684,13 @@ class ClaudeSession implements ProviderSession {
682
684
  // Session ID from init message
683
685
  if (json.type === "system" && json.subtype === "init" && json.session_id) {
684
686
  this._sessionId = json.session_id;
685
- this.emit({ type: "session_init", sessionId: json.session_id, provider: "claude" });
687
+ this.emit({
688
+ type: "session_init",
689
+ sessionId: json.session_id,
690
+ provider: "claude",
691
+ ...(this.harnessVariant ? { harnessVariant: this.harnessVariant } : {}),
692
+ ...(this.harnessVariantMeta ? { harnessVariantMeta: this.harnessVariantMeta } : {}),
693
+ });
686
694
  if (json.model) {
687
695
  // Phase 4: the CLI's `init.model` reflects the actual model after any
688
696
  // backoff/fallback. Update `this.model` so subsequent CostData rows
@@ -970,6 +978,28 @@ export class ClaudeAdapter implements ProviderAdapter {
970
978
  }
971
979
  }
972
980
 
981
+ const harnessVariant = useClaudeBridge ? "bridge" : "stock";
982
+ let harnessVariantMeta: Record<string, unknown> | undefined;
983
+ if (useClaudeBridge) {
984
+ try {
985
+ const bin = effectiveClaudeBinaryArgv[0] ?? "claude-bridge";
986
+ const result = await Bun.$`${bin} --version`.quiet();
987
+ const trimmed = result.text().trim();
988
+ if (trimmed) harnessVariantMeta = { version: trimmed };
989
+ } catch {
990
+ // bridge version is best-effort
991
+ }
992
+ } else {
993
+ try {
994
+ const bin = effectiveClaudeBinaryArgv[0] ?? "claude";
995
+ const result = await Bun.$`${bin} --version`.quiet();
996
+ const trimmed = result.text().trim();
997
+ if (trimmed) harnessVariantMeta = { version: trimmed };
998
+ } catch {
999
+ // stock version is best-effort
1000
+ }
1001
+ }
1002
+
973
1003
  return new ClaudeSession(
974
1004
  config,
975
1005
  model,
@@ -978,6 +1008,8 @@ export class ClaudeAdapter implements ProviderAdapter {
978
1008
  sessionMcpConfig,
979
1009
  effectiveClaudeBinaryArgv,
980
1010
  systemPromptFile,
1011
+ harnessVariant,
1012
+ harnessVariantMeta,
981
1013
  );
982
1014
  }
983
1015
 
@@ -69,6 +69,7 @@ import { scrubSecrets } from "../utils/secret-scrubber";
69
69
  import { computeClaudeManagedCostUsd } from "./claude-managed-models";
70
70
  import { getRuntimeFeePerHour } from "./claude-managed-pricing";
71
71
  import { createClaudeManagedSwarmEventHandler } from "./claude-managed-swarm-events";
72
+ import { readPkgVersion } from "./harness-version";
72
73
  import type {
73
74
  CostData,
74
75
  CredStatus,
@@ -639,11 +640,13 @@ class ClaudeManagedSession implements ProviderSession {
639
640
  // 3. Emit `session_init` once the session is wired up. Listeners
640
641
  // attached via `onEvent` will see this either immediately (if they
641
642
  // attached pre-emit) or via the queue flush.
643
+ const sdkVersion = readPkgVersion("@anthropic-ai/sdk");
642
644
  this.emit({
643
645
  type: "session_init",
644
646
  sessionId: this._sessionId,
645
647
  provider: "claude-managed",
646
648
  providerMeta: { managed: true },
649
+ ...(sdkVersion ? { harnessVariantMeta: { version: sdkVersion } } : {}),
647
650
  });
648
651
 
649
652
  // 4. Drain the SSE stream.
@@ -25,6 +25,8 @@
25
25
 
26
26
  /** Models supported by the managed-agents surface for the swarm worker. */
27
27
  export const CLAUDE_MANAGED_MODELS = [
28
+ "claude-fable-5",
29
+ "claude-mythos-5",
28
30
  "claude-sonnet-4-6",
29
31
  "claude-opus-4-8",
30
32
  "claude-opus-4-7",
@@ -50,6 +52,8 @@ export interface ClaudeManagedModelPricing {
50
52
  * Anthropic public list pricing. Source:
51
53
  * https://platform.claude.com/docs/en/about-claude/pricing
52
54
  *
55
+ * - claude-fable-5: $10 / $50 / $1.00 / $12.50 (verified 2026-06-10)
56
+ * - claude-mythos-5: $10 / $50 / $1.00 / $12.50 (limited availability, verified 2026-06-10)
53
57
  * - claude-sonnet-4-6: $3 / $15 / $0.30 / $3.75 (in / out / cache-read / cache-write)
54
58
  * - claude-opus-4-8: $5 / $25 / $0.50 / $6.25 (verified 2026-05-28)
55
59
  * - claude-opus-4-7: $15 / $75 / $1.50 / $18.75 (STALE — was correct at launch, Anthropic has since dropped Opus to $5/$25)
@@ -57,6 +61,18 @@ export interface ClaudeManagedModelPricing {
57
61
  * - claude-haiku-4-5: $1 / $5 / $0.10 / $1.25
58
62
  */
59
63
  export const CLAUDE_MANAGED_MODEL_PRICING: Record<ClaudeManagedModel, ClaudeManagedModelPricing> = {
64
+ "claude-fable-5": {
65
+ inputPerMillion: 10.0,
66
+ outputPerMillion: 50.0,
67
+ cacheReadPerMillion: 1.0,
68
+ cacheWritePerMillion: 12.5,
69
+ },
70
+ "claude-mythos-5": {
71
+ inputPerMillion: 10.0,
72
+ outputPerMillion: 50.0,
73
+ cacheReadPerMillion: 1.0,
74
+ cacheWritePerMillion: 12.5,
75
+ },
60
76
  "claude-sonnet-4-6": {
61
77
  inputPerMillion: 3.0,
62
78
  outputPerMillion: 15.0,
@@ -83,6 +83,7 @@ import { getValidCodexOAuth } from "./codex-oauth/storage.js";
83
83
  import { resolveCodexPrompt } from "./codex-skill-resolver";
84
84
  import { createCodexSwarmEventHandler } from "./codex-swarm-events";
85
85
  import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
86
+ import { readPkgVersion } from "./harness-version";
86
87
  import { buildOtelTraceparentEnv } from "./otel-env";
87
88
  import type {
88
89
  CostData,
@@ -694,7 +695,13 @@ export class CodexSession implements ProviderSession {
694
695
  switch (event.type) {
695
696
  case "thread.started": {
696
697
  this._sessionId = event.thread_id;
697
- this.emit({ type: "session_init", sessionId: event.thread_id, provider: "codex" });
698
+ const codexVersion = readPkgVersion("@openai/codex-sdk");
699
+ this.emit({
700
+ type: "session_init",
701
+ sessionId: event.thread_id,
702
+ provider: "codex",
703
+ ...(codexVersion ? { harnessVariantMeta: { version: codexVersion } } : {}),
704
+ });
698
705
  break;
699
706
  }
700
707
  case "turn.started": {
@@ -36,6 +36,7 @@ export const CODEX_DEFAULT_MODEL: CodexModel = "gpt-5.4";
36
36
  * a task authored for Claude works unchanged when pointed at a Codex worker.
37
37
  */
38
38
  const CLAUDE_SHORTNAMES: Record<string, CodexModel> = {
39
+ fable: "gpt-5.5",
39
40
  opus: "gpt-5.4",
40
41
  sonnet: "gpt-5.4",
41
42
  haiku: "gpt-5.4-mini",
@@ -48,6 +48,7 @@ export function authJsonToCredentialSelection(auth: CodexAuthJson, slot = 0, tot
48
48
  total,
49
49
  keySuffix: suffixSource.slice(-5),
50
50
  keyType: "CODEX_OAUTH",
51
+ isRateLimitFallback: false,
51
52
  };
52
53
  }
53
54
 
@@ -0,0 +1,7 @@
1
+ export function readPkgVersion(packageName: string): string | undefined {
2
+ try {
3
+ return require(`${packageName}/package.json`).version;
4
+ } catch {
5
+ return undefined;
6
+ }
7
+ }
@@ -21,6 +21,7 @@ import { validateOpencodeCredentials } from "../utils/credentials";
21
21
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
22
22
  import { scrubSecrets } from "../utils/secret-scrubber";
23
23
  import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
24
+ import { readPkgVersion } from "./harness-version";
24
25
  import type {
25
26
  CostData,
26
27
  CredCheckOptions,
@@ -210,7 +211,7 @@ export class OpencodeSession implements ProviderSession {
210
211
  // The runner attaches its listener after `await adapter.createSession(...)`
211
212
  // resolves, but events queued via Promise.resolve().then(...) inside
212
213
  // createSession fire on the next microtask — *before* that listener call —
213
- // so the runner would miss session_init and never PUT /claude-session,
214
+ // so the runner would miss session_init and never PUT /session,
214
215
  // leaving agent_tasks.provider/.model NULL. Buffer + flush on first attach.
215
216
  private pendingEvents: ProviderEvent[] = [];
216
217
  private completionResolve!: (result: ProviderResult) => void;
@@ -280,8 +281,14 @@ export class OpencodeSession implements ProviderSession {
280
281
 
281
282
  /** Emit the synthetic session_init event. Called by the adapter immediately
282
283
  * after construction; buffers if no listener is attached yet. */
283
- emitSessionInit(provider: "opencode"): void {
284
- this.emit({ type: "session_init", sessionId: this._sessionId, provider });
284
+ emitSessionInit(provider: "opencode", harnessVariantMeta?: Record<string, unknown>): void {
285
+ this.emit({
286
+ type: "session_init",
287
+ sessionId: this._sessionId,
288
+ provider,
289
+ harnessVariant: "stock",
290
+ ...(harnessVariantMeta ? { harnessVariantMeta } : {}),
291
+ });
285
292
  }
286
293
 
287
294
  onEvent(listener: (event: ProviderEvent) => void): void {
@@ -767,7 +774,8 @@ export class OpencodeAdapter implements ProviderAdapter {
767
774
 
768
775
  // Emit session_init synchronously; the session buffers events until the
769
776
  // runner's `onEvent(listener)` call attaches a listener.
770
- session.emitSessionInit("opencode");
777
+ const opcVersion = readPkgVersion("@opencode-ai/sdk");
778
+ session.emitSessionInit("opencode", opcVersion ? { version: opcVersion } : undefined);
771
779
 
772
780
  // Subscribe to SSE events and drive the session
773
781
  client.event
@@ -25,7 +25,9 @@ import {
25
25
  SessionManager,
26
26
  } from "@earendil-works/pi-coding-agent";
27
27
  import { type TSchema, Type } from "typebox";
28
+ import { classifyAwsSdkError } from "../utils/aws-error-classifier";
28
29
  import { scrubSecrets } from "../utils/secret-scrubber";
30
+ import { readPkgVersion } from "./harness-version";
29
31
  import { createSwarmHooksExtension } from "./pi-mono-extension";
30
32
  import { McpHttpClient } from "./pi-mono-mcp-client";
31
33
  import type {
@@ -173,6 +175,7 @@ function mcpToolsToDefinitions(
173
175
  * (`anthropic/claude-{opus,sonnet,haiku}-*`).
174
176
  */
175
177
  const ANTHROPIC_SHORTNAME_OPENROUTER_MIRROR: Record<string, string> = {
178
+ fable: "anthropic/claude-fable-5",
176
179
  opus: "anthropic/claude-opus-4",
177
180
  sonnet: "anthropic/claude-sonnet-4",
178
181
  haiku: "anthropic/claude-haiku-4.5",
@@ -233,7 +236,8 @@ export function resolveModel(
233
236
  if (!modelStr) return undefined;
234
237
 
235
238
  const lower = modelStr.toLowerCase();
236
- const isAnthropicShortname = lower === "opus" || lower === "sonnet" || lower === "haiku";
239
+ const isAnthropicShortname =
240
+ lower === "opus" || lower === "sonnet" || lower === "haiku" || lower === "fable";
237
241
 
238
242
  // Reroute anthropic shortnames through OpenRouter when no anthropic cred
239
243
  // is available. The OpenRouter mirror IDs (`anthropic/claude-sonnet-4`,
@@ -251,6 +255,7 @@ export function resolveModel(
251
255
 
252
256
  // Map common shortnames to provider/model pairs (native anthropic path).
253
257
  const shortnames: Record<string, [string, string]> = {
258
+ fable: ["anthropic", "claude-fable-5"],
254
259
  opus: ["anthropic", "claude-opus-4-20250514"],
255
260
  sonnet: ["anthropic", "claude-sonnet-4-20250514"],
256
261
  haiku: ["anthropic", "claude-haiku-4-5-20251001"],
@@ -357,6 +362,18 @@ export class PiMonoSession implements ProviderSession {
357
362
  * surface it directly.
358
363
  */
359
364
  private prevOutputTokens = 0;
365
+ /**
366
+ * Terminal error message captured from structured pi-coding-agent events.
367
+ *
368
+ * Set by `message_end` (assistant turn with `stopReason==='error'` — covers
369
+ * NON-retryable failures, including AWS auth which never enters pi's retry
370
+ * loop) and by `auto_retry_end` with `success:false` (the definitive terminal
371
+ * failure after the retryable class — throttle / 5xx / timeout — exhausts).
372
+ * Cleared on recovery: a successful `message_end` or an `auto_retry_end` with
373
+ * `success:true` resets it to null, so a recovered error never surfaces as a
374
+ * false failure. Evaluated once at session end in `runSession()`.
375
+ */
376
+ private terminalError: string | null = null;
360
377
 
361
378
  constructor(agentSession: AgentSession, config: ProviderSessionConfig, createdSymlink: boolean) {
362
379
  this.agentSession = agentSession;
@@ -367,7 +384,14 @@ export class PiMonoSession implements ProviderSession {
367
384
  this.sessionStartedAt = Date.now();
368
385
 
369
386
  // Emit session_init immediately
370
- this.emit({ type: "session_init", sessionId: this._sessionId, provider: "pi" });
387
+ const piVersion = readPkgVersion("@earendil-works/pi-coding-agent");
388
+ this.emit({
389
+ type: "session_init",
390
+ sessionId: this._sessionId,
391
+ provider: "pi",
392
+ harnessVariant: "stock",
393
+ ...(piVersion ? { harnessVariantMeta: { version: piVersion } } : {}),
394
+ });
371
395
 
372
396
  // Subscribe to agent events and normalize
373
397
  this.agentSession.subscribe((event) => this.handleAgentEvent(event));
@@ -414,6 +438,25 @@ export class PiMonoSession implements ProviderSession {
414
438
  switch (event.type) {
415
439
  case "message_end": {
416
440
  // Pi emits message_end for user, assistant, and tool-result messages.
441
+ // An assistant turn that ended in `stopReason==='error'` is a failed
442
+ // turn — track it as the (so far) terminal error. This is the ONLY
443
+ // structured signal for NON-retryable failures (AWS auth: ExpiredToken
444
+ // / CredentialsProviderError), which never enter pi's retry loop.
445
+ const endMsg = event.message as {
446
+ role?: string;
447
+ stopReason?: string;
448
+ errorMessage?: string;
449
+ };
450
+ if (endMsg.role === "assistant") {
451
+ if (endMsg.stopReason === "error") {
452
+ // Candidate terminal failure. May still be cleared by a successful
453
+ // retry (auto_retry_end success / a later good message_end).
454
+ this.terminalError = endMsg.errorMessage ?? this.terminalError ?? "Unknown error";
455
+ break;
456
+ }
457
+ // A successful assistant turn means any prior error has recovered.
458
+ this.terminalError = null;
459
+ }
417
460
  // Only assistant text should be printed or used as fallback output.
418
461
  const text = extractPiAssistantText(event.message);
419
462
  if (text) {
@@ -507,12 +550,18 @@ export class PiMonoSession implements ProviderSession {
507
550
  result: event.result,
508
551
  });
509
552
  break;
510
- case "auto_retry_start":
511
- this.emit({
512
- type: "raw_stderr",
513
- content: `[pi-mono] Auto-retry attempt ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}\n`,
514
- });
553
+ case "auto_retry_end": {
554
+ // Definitive terminal signal for the RETRYABLE error class
555
+ // (throttle / 5xx / timeout). pi-coding-agent emits success:false with
556
+ // `finalError` only after every retry attempt is exhausted; success:true
557
+ // means the turn recovered, so clear any tracked error.
558
+ if (event.success) {
559
+ this.terminalError = null;
560
+ } else {
561
+ this.terminalError = event.finalError ?? this.terminalError ?? "Unknown error";
562
+ }
515
563
  break;
564
+ }
516
565
  }
517
566
  }
518
567
 
@@ -530,6 +579,26 @@ export class PiMonoSession implements ProviderSession {
530
579
  const stats = this.agentSession.getSessionStats();
531
580
  const cost = this.buildCostData(stats);
532
581
 
582
+ // A structured terminal error from pi-coding-agent events is failure by
583
+ // definition (the agent already exhausted retries or hit a non-retryable
584
+ // error). Surface it so the session-chat red box fires and the task fails,
585
+ // exactly like sibling adapters. AWS errors get a categorized, actionable
586
+ // message; anything else surfaces its raw error text.
587
+ if (this.terminalError) {
588
+ const classification = classifyAwsSdkError(this.terminalError);
589
+ const message = classification?.message ?? this.terminalError;
590
+ const category = classification?.category;
591
+ this.emit({ type: "error", message, category });
592
+ return {
593
+ exitCode: 1,
594
+ sessionId: this._sessionId,
595
+ cost,
596
+ isError: true,
597
+ errorCategory: category,
598
+ failureReason: message,
599
+ };
600
+ }
601
+
533
602
  this.emit({
534
603
  type: "result",
535
604
  cost,
@@ -545,13 +614,26 @@ export class PiMonoSession implements ProviderSession {
545
614
  };
546
615
  } catch (err) {
547
616
  const errorMessage = err instanceof Error ? err.message : String(err);
617
+ // Defense-in-depth: AWS SDK failures surface as structured events (handled
618
+ // above in runSession), not thrown exceptions, so this catch is for genuine
619
+ // unexpected throws (MCP / transport / etc). Still classify in case an AWS
620
+ // signature ever reaches here, so the red box fires like sibling adapters.
621
+ const awsCatchError = classifyAwsSdkError(errorMessage);
622
+ if (awsCatchError) {
623
+ this.emit({
624
+ type: "error",
625
+ message: awsCatchError.message,
626
+ category: awsCatchError.category,
627
+ });
628
+ }
548
629
  this.emit({ type: "raw_stderr", content: `[pi-mono] Error: ${errorMessage}\n` });
549
630
 
550
631
  return {
551
632
  exitCode: 1,
552
633
  sessionId: this._sessionId,
553
634
  isError: true,
554
- failureReason: errorMessage,
635
+ errorCategory: awsCatchError?.category,
636
+ failureReason: awsCatchError?.message ?? errorMessage,
555
637
  };
556
638
  } finally {
557
639
  await this.logFileHandle.end();
@@ -42,6 +42,8 @@ export type ProviderEvent =
42
42
  sessionId: string;
43
43
  provider?: ProviderName;
44
44
  providerMeta?: Record<string, unknown>;
45
+ harnessVariant?: string;
46
+ harnessVariantMeta?: Record<string, unknown>;
45
47
  }
46
48
  | { type: "message"; role: "assistant" | "user"; content: string }
47
49
  | { type: "tool_start"; toolCallId: string; toolName: string; args: unknown }
@@ -3,7 +3,7 @@ import { CronExpressionParser } from "cron-parser";
3
3
  import { getDb, getDueScheduledTasks, getScheduledTaskById, updateScheduledTask } from "@/be/db";
4
4
  import { scheduleContextKey } from "@/tasks/context-key";
5
5
  import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
6
- import type { ScheduledTask } from "@/types";
6
+ import type { AgentTask, ScheduledTask } from "@/types";
7
7
  import type { ExecutorRegistry } from "@/workflows/executors/registry";
8
8
  import { handleScheduleTrigger } from "@/workflows/triggers";
9
9
 
@@ -11,6 +11,24 @@ let schedulerInterval: ReturnType<typeof setInterval> | null = null;
11
11
  let isProcessing = false;
12
12
  let executorRegistry: ExecutorRegistry | null = null;
13
13
 
14
+ export function createStandaloneScheduleTask(
15
+ schedule: ScheduledTask,
16
+ extraTags: string[] = [],
17
+ ): AgentTask {
18
+ return createTaskWithSiblingAwareness(schedule.taskTemplate, {
19
+ creatorAgentId: schedule.createdByAgentId,
20
+ taskType: schedule.taskType,
21
+ tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, ...extraTags],
22
+ priority: schedule.priority,
23
+ agentId: schedule.targetAgentId,
24
+ model: schedule.model,
25
+ modelTier: schedule.modelTier,
26
+ scheduleId: schedule.id,
27
+ source: "schedule",
28
+ contextKey: scheduleContextKey({ scheduleId: schedule.id }),
29
+ });
30
+ }
31
+
14
32
  /**
15
33
  * Recover missed scheduled task runs from downtime.
16
34
  * Fires ONE catch-up run per schedule (not N missed runs).
@@ -45,17 +63,7 @@ async function recoverMissedSchedules(): Promise<void> {
45
63
 
46
64
  if (!triggeredWorkflows) {
47
65
  const tx = getDb().transaction(() => {
48
- createTaskWithSiblingAwareness(schedule.taskTemplate, {
49
- creatorAgentId: schedule.createdByAgentId,
50
- taskType: schedule.taskType,
51
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "recovered"],
52
- priority: schedule.priority,
53
- agentId: schedule.targetAgentId,
54
- model: schedule.model,
55
- scheduleId: schedule.id,
56
- source: "schedule",
57
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
58
- });
66
+ createStandaloneScheduleTask(schedule, ["recovered"]);
59
67
  });
60
68
  tx();
61
69
  }
@@ -150,17 +158,7 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
150
158
  if (!triggeredWorkflows) {
151
159
  // No workflows linked — create standalone task (existing behavior)
152
160
  getDb().transaction(() => {
153
- createTaskWithSiblingAwareness(schedule.taskTemplate, {
154
- creatorAgentId: schedule.createdByAgentId,
155
- taskType: schedule.taskType,
156
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`],
157
- priority: schedule.priority,
158
- agentId: schedule.targetAgentId,
159
- model: schedule.model,
160
- scheduleId: schedule.id,
161
- source: "schedule",
162
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
163
- });
161
+ createStandaloneScheduleTask(schedule);
164
162
  })();
165
163
  }
166
164
 
@@ -341,17 +339,7 @@ export async function runScheduleNow(scheduleId: string): Promise<void> {
341
339
  if (!triggeredWorkflows) {
342
340
  // No workflows linked — create standalone task (existing behavior)
343
341
  getDb().transaction(() => {
344
- createTaskWithSiblingAwareness(schedule.taskTemplate, {
345
- creatorAgentId: schedule.createdByAgentId,
346
- taskType: schedule.taskType,
347
- tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
348
- priority: schedule.priority,
349
- agentId: schedule.targetAgentId,
350
- model: schedule.model,
351
- scheduleId: schedule.id,
352
- source: "schedule",
353
- contextKey: scheduleContextKey({ scheduleId: schedule.id }),
354
- });
342
+ createStandaloneScheduleTask(schedule, ["manual-run"]);
355
343
  })();
356
344
  }
357
345