@desplega.ai/agent-swarm 1.93.0 → 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 (67) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +180 -1
  3. package/package.json +1 -1
  4. package/src/be/db.ts +63 -7
  5. package/src/be/migrations/090_model_tiers.sql +2 -0
  6. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  7. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  8. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  9. package/src/be/migrations/runner.ts +52 -0
  10. package/src/be/modelsdev-cache.json +2060 -198
  11. package/src/be/scripts/boot-reembed.ts +74 -0
  12. package/src/be/scripts/db.ts +19 -3
  13. package/src/be/seed/index.ts +1 -1
  14. package/src/be/seed/registry.ts +2 -2
  15. package/src/be/seed/runner.ts +5 -5
  16. package/src/be/seed/types.ts +6 -1
  17. package/src/be/seed-pricing.ts +1 -0
  18. package/src/be/seed-scripts/index.ts +3 -2
  19. package/src/commands/runner.ts +83 -13
  20. package/src/http/index.ts +13 -2
  21. package/src/http/metrics.ts +55 -6
  22. package/src/http/schedules.ts +16 -15
  23. package/src/http/script-runs.ts +7 -1
  24. package/src/http/scripts.ts +147 -1
  25. package/src/http/tasks.ts +7 -0
  26. package/src/model-tiers.ts +140 -0
  27. package/src/providers/claude-managed-models.ts +9 -0
  28. package/src/providers/opencode-adapter.ts +1 -0
  29. package/src/providers/pi-mono-adapter.ts +78 -6
  30. package/src/scheduler/scheduler.ts +22 -34
  31. package/src/server-user.ts +8 -2
  32. package/src/slack/responses.ts +39 -11
  33. package/src/slack/watcher.ts +121 -8
  34. package/src/tests/agents-list-model-display.test.ts +13 -0
  35. package/src/tests/aws-error-classifier.test.ts +148 -0
  36. package/src/tests/claude-managed-adapter.test.ts +12 -0
  37. package/src/tests/context-window.test.ts +7 -0
  38. package/src/tests/http-api-integration.test.ts +19 -0
  39. package/src/tests/metrics-http.test.ts +137 -3
  40. package/src/tests/migration-046-budgets.test.ts +33 -0
  41. package/src/tests/migration-runner-regressions.test.ts +69 -0
  42. package/src/tests/model-control.test.ts +162 -46
  43. package/src/tests/opencode-adapter.test.ts +9 -0
  44. package/src/tests/pi-mono-adapter.test.ts +319 -0
  45. package/src/tests/providers/pi-cost.test.ts +9 -0
  46. package/src/tests/runner-fallback-output.test.ts +50 -0
  47. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  48. package/src/tests/scripts-embeddings.test.ts +90 -0
  49. package/src/tests/seed.test.ts +26 -1
  50. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  51. package/src/tests/slack-watcher.test.ts +66 -0
  52. package/src/tests/workflow-agent-task.test.ts +5 -2
  53. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  54. package/src/tools/memory-get.ts +11 -0
  55. package/src/tools/memory-search.ts +18 -0
  56. package/src/tools/schedules/create-schedule.ts +71 -70
  57. package/src/tools/schedules/update-schedule.ts +43 -31
  58. package/src/tools/send-task.ts +16 -5
  59. package/src/tools/task-action.ts +11 -3
  60. package/src/types.ts +29 -0
  61. package/src/utils/aws-error-classifier.ts +97 -0
  62. package/src/utils/context-window.ts +2 -0
  63. package/src/utils/credentials.test.ts +68 -0
  64. package/src/utils/credentials.ts +44 -3
  65. package/src/utils/pretty-print.ts +25 -10
  66. package/src/workflows/engine.ts +3 -2
  67. package/src/workflows/executors/agent-task.ts +3 -1
@@ -51,6 +51,7 @@ const createScriptRunBodySchema = z.object({
51
51
  const listScriptRunsQuerySchema = z.object({
52
52
  status: ScriptRunStatusSchema.optional(),
53
53
  agentId: z.string().optional(),
54
+ scriptName: z.string().optional(),
54
55
  limit: z.coerce.number().int().min(1).max(500).optional(),
55
56
  offset: z.coerce.number().int().min(0).optional(),
56
57
  });
@@ -367,12 +368,17 @@ export async function handleScriptRuns(
367
368
  const opts = {
368
369
  status: parsed.query.status,
369
370
  agentId: parsed.query.agentId,
371
+ scriptName: parsed.query.scriptName,
370
372
  limit: parsed.query.limit ?? 50,
371
373
  offset: parsed.query.offset ?? 0,
372
374
  };
373
375
  json(res, {
374
376
  runs: listScriptRuns(opts),
375
- total: countScriptRuns({ status: opts.status, agentId: opts.agentId }),
377
+ total: countScriptRuns({
378
+ status: opts.status,
379
+ agentId: opts.agentId,
380
+ scriptName: opts.scriptName,
381
+ }),
376
382
  });
377
383
  return true;
378
384
  }
@@ -2,14 +2,23 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { getAgentById, recordInlineScriptRun, upsertKv } from "../be/db";
4
4
  import { createEvent } from "../be/events";
5
- import { deleteScript, getScript, upsertScriptByName } from "../be/scripts/db";
5
+ import {
6
+ deleteScript,
7
+ getScript,
8
+ getScriptById,
9
+ listScripts,
10
+ listScriptVersions,
11
+ upsertScriptByName,
12
+ } from "../be/scripts/db";
6
13
  import { searchScripts } from "../be/scripts/embeddings";
7
14
  import { extractArgsJsonSchema } from "../be/scripts/extract-schema";
8
15
  import { SCRIPT_SDK_TYPES, SCRIPT_STDLIB_TYPES, typecheckScript } from "../be/scripts/typecheck";
9
16
  import { extractScriptSignature } from "../scripts-runtime/extract-signature";
10
17
  import { runScript } from "../scripts-runtime/loader";
11
18
  import {
19
+ type ScriptDetail,
12
20
  ScriptFsModeSchema,
21
+ type ScriptListItem,
13
22
  type ScriptRecord,
14
23
  type ScriptScope,
15
24
  ScriptScopeSchema,
@@ -52,6 +61,11 @@ const searchBodySchema = z.object({
52
61
  const nameParamsSchema = z.object({ name: scriptNameSchema });
53
62
  const scopeQuerySchema = z.object({ scope: ScriptScopeSchema.default("agent") });
54
63
  const optionalScopeQuerySchema = z.object({ scope: ScriptScopeSchema.optional() });
64
+ const idParamsSchema = z.object({ id: z.string().uuid() });
65
+ const listScriptsQuerySchema = z.object({
66
+ scope: ScriptScopeSchema.optional(),
67
+ includeScratch: z.enum(["true", "false"]).optional(),
68
+ });
55
69
 
56
70
  const upsertRoute = route({
57
71
  method: "post",
@@ -133,6 +147,74 @@ const typesRoute = route({
133
147
  },
134
148
  });
135
149
 
150
+ // ── Dashboard read routes ──
151
+ // The worker-facing routes above resolve scripts relative to the calling agent
152
+ // and therefore requireAgent (X-Agent-ID). The routes below are cross-scope
153
+ // admin reads for the dashboard: API-key auth only, no agent identity — the
154
+ // same model as /api/script-runs.
155
+
156
+ const listScriptsRoute = route({
157
+ method: "get",
158
+ path: "/api/scripts",
159
+ pattern: ["api", "scripts"],
160
+ operationId: "scripts_list",
161
+ summary: "List saved scripts",
162
+ description:
163
+ "Dashboard read: lean projection without source. Scratch scripts are excluded unless includeScratch=true.",
164
+ tags: ["Scripts"],
165
+ query: listScriptsQuerySchema,
166
+ responses: {
167
+ 200: { description: "Saved scripts" },
168
+ 400: { description: "Validation error" },
169
+ },
170
+ });
171
+
172
+ // Declared (and matched) BEFORE the by-id route: the by-id pattern
173
+ // ["api", "scripts", null] matches any single segment, so the literal
174
+ // "type-defs" segment must win first.
175
+ const typeDefsRoute = route({
176
+ method: "get",
177
+ path: "/api/scripts/type-defs",
178
+ pattern: ["api", "scripts", "type-defs"],
179
+ operationId: "scripts_type_defs",
180
+ summary: "Get script SDK and stdlib type definitions",
181
+ description: "Static .d.ts blobs for editor integration (e.g. Monaco extraLibs). Cacheable.",
182
+ tags: ["Scripts"],
183
+ responses: {
184
+ 200: { description: "SDK and stdlib type definition blobs" },
185
+ },
186
+ });
187
+
188
+ const getScriptByIdRoute = route({
189
+ method: "get",
190
+ path: "/api/scripts/{id}",
191
+ pattern: ["api", "scripts", null],
192
+ operationId: "scripts_get",
193
+ summary: "Get a saved script by id",
194
+ description: "Dashboard read: full record including source and parsed signature.",
195
+ tags: ["Scripts"],
196
+ params: idParamsSchema,
197
+ responses: {
198
+ 200: { description: "Script detail" },
199
+ 404: { description: "Script not found" },
200
+ },
201
+ });
202
+
203
+ const listVersionsRoute = route({
204
+ method: "get",
205
+ path: "/api/scripts/{id}/versions",
206
+ pattern: ["api", "scripts", null, "versions"],
207
+ operationId: "scripts_versions",
208
+ summary: "List versions of a saved script",
209
+ description: "Dashboard read: version history, newest first.",
210
+ tags: ["Scripts"],
211
+ params: idParamsSchema,
212
+ responses: {
213
+ 200: { description: "Script versions" },
214
+ 404: { description: "Script not found" },
215
+ },
216
+ });
217
+
136
218
  function requireAgent(res: ServerResponse, agentId: string | undefined) {
137
219
  if (!agentId) {
138
220
  jsonError(res, "X-Agent-ID required for scripts API", 400);
@@ -413,6 +495,70 @@ export async function handleScripts(
413
495
  return true;
414
496
  }
415
497
 
498
+ // ── Dashboard reads (no requireAgent — API-key auth only, like /api/script-runs) ──
499
+
500
+ if (listScriptsRoute.match(req.method, pathSegments)) {
501
+ const parsed = await listScriptsRoute.parse(req, res, pathSegments, queryParams);
502
+ if (!parsed) return true;
503
+ const scripts: ScriptListItem[] = listScripts({
504
+ scope: parsed.query.scope,
505
+ includeScratch: parsed.query.includeScratch === "true",
506
+ }).map((script) => ({
507
+ id: script.id,
508
+ name: script.name,
509
+ scope: script.scope,
510
+ scopeId: script.scopeId,
511
+ description: script.description,
512
+ intent: script.intent,
513
+ version: script.version,
514
+ isScratch: script.isScratch,
515
+ typeChecked: script.typeChecked,
516
+ fsMode: script.fsMode,
517
+ createdByAgentId: script.createdByAgentId,
518
+ createdAt: script.createdAt,
519
+ updatedAt: script.updatedAt,
520
+ }));
521
+ json(res, { scripts });
522
+ return true;
523
+ }
524
+
525
+ // Must be matched before getScriptByIdRoute — its ["api", "scripts", null]
526
+ // pattern would otherwise swallow the literal "type-defs" segment.
527
+ if (typeDefsRoute.match(req.method, pathSegments)) {
528
+ json(res, { sdkTypes: SCRIPT_SDK_TYPES, stdlibTypes: SCRIPT_STDLIB_TYPES });
529
+ return true;
530
+ }
531
+
532
+ if (getScriptByIdRoute.match(req.method, pathSegments)) {
533
+ const parsed = await getScriptByIdRoute.parse(req, res, pathSegments, queryParams);
534
+ if (!parsed) return true;
535
+ const script = getScriptById(parsed.params.id);
536
+ if (!script) {
537
+ jsonError(res, "Script not found", 404);
538
+ return true;
539
+ }
540
+ // `source` is author-supplied TS (same trust surface as script_runs.source,
541
+ // already served raw by GET /api/script-runs/{id}) — no env/secret material.
542
+ const detail: ScriptDetail = {
543
+ ...script,
544
+ signature: JSON.parse(script.signatureJson) as unknown,
545
+ argsJsonSchema: script.argsJsonSchema ? (JSON.parse(script.argsJsonSchema) as unknown) : null,
546
+ };
547
+ json(res, { script: detail });
548
+ return true;
549
+ }
550
+
551
+ if (listVersionsRoute.match(req.method, pathSegments)) {
552
+ const parsed = await listVersionsRoute.parse(req, res, pathSegments, queryParams);
553
+ if (!parsed) return true;
554
+ if (!getScriptById(parsed.params.id)) {
555
+ jsonError(res, "Script not found", 404);
556
+ return true;
557
+ }
558
+ json(res, { versions: listScriptVersions(parsed.params.id) });
559
+ return true;
560
+ }
561
+
416
562
  if (typesRoute.match(req.method, pathSegments)) {
417
563
  const parsed = await typesRoute.parse(req, res, pathSegments, queryParams);
418
564
  if (!parsed) return true;
package/src/http/tasks.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  updateTaskProgress,
24
24
  updateTaskVcs,
25
25
  } from "../be/db";
26
+ import { ModelTierSchema, splitLegacyModelAlias } from "../model-tiers";
26
27
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
27
28
  import { createResumeFollowUp, createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
28
29
  import { telemetry } from "../telemetry";
@@ -91,6 +92,8 @@ const createTask = route({
91
92
  outputSchema: z.record(z.string(), z.unknown()).optional(),
92
93
  contextKey: z.string().optional(),
93
94
  requestedByUserId: z.string().optional(),
95
+ model: z.string().optional(),
96
+ modelTier: ModelTierSchema.optional(),
94
97
  }),
95
98
  responses: {
96
99
  201: { description: "Task created" },
@@ -395,6 +398,10 @@ export async function handleTasks(
395
398
  outputSchema: parsed.body.outputSchema || undefined,
396
399
  contextKey: parsed.body.contextKey || undefined,
397
400
  requestedByUserId,
401
+ ...splitLegacyModelAlias({
402
+ model: parsed.body.model,
403
+ modelTier: parsed.body.modelTier,
404
+ }),
398
405
  });
399
406
 
400
407
  ensure({
@@ -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
+ }
@@ -26,6 +26,7 @@
26
26
  /** Models supported by the managed-agents surface for the swarm worker. */
27
27
  export const CLAUDE_MANAGED_MODELS = [
28
28
  "claude-fable-5",
29
+ "claude-mythos-5",
29
30
  "claude-sonnet-4-6",
30
31
  "claude-opus-4-8",
31
32
  "claude-opus-4-7",
@@ -51,6 +52,8 @@ export interface ClaudeManagedModelPricing {
51
52
  * Anthropic public list pricing. Source:
52
53
  * https://platform.claude.com/docs/en/about-claude/pricing
53
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)
54
57
  * - claude-sonnet-4-6: $3 / $15 / $0.30 / $3.75 (in / out / cache-read / cache-write)
55
58
  * - claude-opus-4-8: $5 / $25 / $0.50 / $6.25 (verified 2026-05-28)
56
59
  * - claude-opus-4-7: $15 / $75 / $1.50 / $18.75 (STALE — was correct at launch, Anthropic has since dropped Opus to $5/$25)
@@ -64,6 +67,12 @@ export const CLAUDE_MANAGED_MODEL_PRICING: Record<ClaudeManagedModel, ClaudeMana
64
67
  cacheReadPerMillion: 1.0,
65
68
  cacheWritePerMillion: 12.5,
66
69
  },
70
+ "claude-mythos-5": {
71
+ inputPerMillion: 10.0,
72
+ outputPerMillion: 50.0,
73
+ cacheReadPerMillion: 1.0,
74
+ cacheWritePerMillion: 12.5,
75
+ },
67
76
  "claude-sonnet-4-6": {
68
77
  inputPerMillion: 3.0,
69
78
  outputPerMillion: 15.0,
@@ -286,6 +286,7 @@ export class OpencodeSession implements ProviderSession {
286
286
  type: "session_init",
287
287
  sessionId: this._sessionId,
288
288
  provider,
289
+ harnessVariant: "stock",
289
290
  ...(harnessVariantMeta ? { harnessVariantMeta } : {}),
290
291
  });
291
292
  }
@@ -25,6 +25,7 @@ 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";
29
30
  import { readPkgVersion } from "./harness-version";
30
31
  import { createSwarmHooksExtension } from "./pi-mono-extension";
@@ -361,6 +362,18 @@ export class PiMonoSession implements ProviderSession {
361
362
  * surface it directly.
362
363
  */
363
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;
364
377
 
365
378
  constructor(agentSession: AgentSession, config: ProviderSessionConfig, createdSymlink: boolean) {
366
379
  this.agentSession = agentSession;
@@ -376,6 +389,7 @@ export class PiMonoSession implements ProviderSession {
376
389
  type: "session_init",
377
390
  sessionId: this._sessionId,
378
391
  provider: "pi",
392
+ harnessVariant: "stock",
379
393
  ...(piVersion ? { harnessVariantMeta: { version: piVersion } } : {}),
380
394
  });
381
395
 
@@ -424,6 +438,25 @@ export class PiMonoSession implements ProviderSession {
424
438
  switch (event.type) {
425
439
  case "message_end": {
426
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
+ }
427
460
  // Only assistant text should be printed or used as fallback output.
428
461
  const text = extractPiAssistantText(event.message);
429
462
  if (text) {
@@ -517,12 +550,18 @@ export class PiMonoSession implements ProviderSession {
517
550
  result: event.result,
518
551
  });
519
552
  break;
520
- case "auto_retry_start":
521
- this.emit({
522
- type: "raw_stderr",
523
- content: `[pi-mono] Auto-retry attempt ${event.attempt}/${event.maxAttempts}: ${event.errorMessage}\n`,
524
- });
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
+ }
525
563
  break;
564
+ }
526
565
  }
527
566
  }
528
567
 
@@ -540,6 +579,26 @@ export class PiMonoSession implements ProviderSession {
540
579
  const stats = this.agentSession.getSessionStats();
541
580
  const cost = this.buildCostData(stats);
542
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
+
543
602
  this.emit({
544
603
  type: "result",
545
604
  cost,
@@ -555,13 +614,26 @@ export class PiMonoSession implements ProviderSession {
555
614
  };
556
615
  } catch (err) {
557
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
+ }
558
629
  this.emit({ type: "raw_stderr", content: `[pi-mono] Error: ${errorMessage}\n` });
559
630
 
560
631
  return {
561
632
  exitCode: 1,
562
633
  sessionId: this._sessionId,
563
634
  isError: true,
564
- failureReason: errorMessage,
635
+ errorCategory: awsCatchError?.category,
636
+ failureReason: awsCatchError?.message ?? errorMessage,
565
637
  };
566
638
  } finally {
567
639
  await this.logFileHandle.end();
@@ -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
 
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import pkg from "../package.json";
4
+ import { ModelTierSchema } from "./model-tiers";
4
5
  import {
5
6
  cancelTaskHandler,
6
7
  cancelTaskInputSchema,
@@ -28,9 +29,14 @@ const userSendTaskInputSchema = z.object({
28
29
  tags: z.array(z.string()).optional().describe("Tags for filtering (e.g., ['urgent'])."),
29
30
  priority: z.number().int().min(0).max(100).optional().describe("Priority 0-100 (default: 50)."),
30
31
  model: z
31
- .enum(["haiku", "sonnet", "opus", "fable"])
32
+ .string()
33
+ .trim()
34
+ .min(1)
32
35
  .optional()
33
- .describe("Model to use for this task ('haiku', 'sonnet', 'opus', or 'fable')."),
36
+ .describe("Concrete model override interpreted by the assignee's harness/provider."),
37
+ modelTier: ModelTierSchema.optional().describe(
38
+ "Portable model tier: 'smol', 'regular', 'smart', or 'ultra'. Resolved by the assignee's harness/provider.",
39
+ ),
34
40
  });
35
41
 
36
42
  export function createUserServer(user: User): McpServer {