@desplega.ai/agent-swarm 2.5.2 → 2.5.3

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 (143) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-6xx079d1.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-ft6r6v23.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-fkbyk66x.js} +2 -2
  14. package/dist/{cli-d172svcw.js → cli-1fc85mvt.js} +4 -4
  15. package/dist/{cli-3e9vs93g.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-vaxw3bg0.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-fgn7yg5r.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-7ccf7akh.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-b1tv22wr.js → cli-502eqv93.js} +1 -1
  21. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  22. package/dist/{cli-f55b6ky0.js → cli-8s5wx3nz.js} +2 -2
  23. package/dist/{cli-86zpfje5.js → cli-99xgw26r.js} +1 -1
  24. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  25. package/dist/{cli-h5d22pfs.js → cli-aecdcr77.js} +119 -282
  26. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  27. package/dist/{cli-15xd0j7w.js → cli-ce0ak9ms.js} +11 -11
  28. package/dist/{cli-y3dzwyy3.js → cli-e2ar4bp1.js} +71 -20
  29. package/dist/{cli-b6tnjdvx.js → cli-e8jyq7d8.js} +1 -1
  30. package/dist/{cli-6y43v79h.js → cli-egjggehm.js} +1 -1
  31. package/dist/{cli-cf63dnxj.js → cli-ewm3pwbq.js} +2 -2
  32. package/dist/{cli-bpbcpd8b.js → cli-fsdprwrg.js} +1 -1
  33. package/dist/{cli-qrjv7t18.js → cli-fzcn3d87.js} +1 -1
  34. package/dist/{cli-14v1sxxy.js → cli-gpsz7g88.js} +6 -6
  35. package/dist/{cli-wadzapwb.js → cli-hcpj2xqz.js} +1 -1
  36. package/dist/{cli-jmcjbjkt.js → cli-hhxjnjzs.js} +1 -1
  37. package/dist/{cli-j3dwd4kz.js → cli-jjznk3f7.js} +718 -108
  38. package/dist/{cli-csh8fnbg.js → cli-kxawjrxg.js} +147 -28
  39. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  40. package/dist/{cli-0vgy5wfw.js → cli-n45mr7zh.js} +2 -2
  41. package/dist/{cli-aaqnqpyw.js → cli-p0rzjkya.js} +2 -2
  42. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  43. package/dist/{cli-e91vshb1.js → cli-p9rafdrb.js} +4 -4
  44. package/dist/{cli-edtm741g.js → cli-qgf8emrn.js} +5 -5
  45. package/dist/{cli-ha22h0eg.js → cli-qzme5949.js} +26 -26
  46. package/dist/{cli-xzge46sj.js → cli-s49hpn37.js} +2 -2
  47. package/dist/{cli-b63d08rs.js → cli-s53vp709.js} +1 -1
  48. package/dist/{cli-cyfgxnvb.js → cli-svrj71pg.js} +18 -16
  49. package/dist/{cli-4qkg9968.js → cli-vwn81jbs.js} +3 -3
  50. package/dist/{cli-f3pfn4x3.js → cli-w1zwctj2.js} +1 -1
  51. package/dist/{cli-kcnwn85g.js → cli-y561h5a9.js} +4 -4
  52. package/dist/{cli-n2918sed.js → cli-z21whrrg.js} +15 -15
  53. package/dist/cli.js +16 -16
  54. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-n8ka66vf.js} +2 -2
  55. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-fsebts37.js} +1 -1
  56. package/dist/{commands-1cnvseqx.js → commands-t04rt2pc.js} +2 -2
  57. package/dist/{controller-kc7s9xhn.js → controller-rgna04r2.js} +2 -2
  58. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-bqm1rtft.js} +20 -20
  59. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-swr2k95y.js} +1 -1
  60. package/dist/{db-v5ytg5pw.js → db-24ck09xy.js} +1 -1
  61. package/dist/{db-4vq88jkc.js → db-a4m7haw0.js} +3 -3
  62. package/dist/{dispatcher-trtcrqae.js → dispatcher-nwf9ss0q.js} +17 -17
  63. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-kpdnhjst.js} +2 -2
  64. package/dist/{external-channel-n9bymc2n.js → external-channel-j1e2p34p.js} +2 -2
  65. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-qtjz0whk.js} +4 -4
  66. package/dist/{handlers-ptsraqa8.js → handlers-19xr6dnz.js} +20 -20
  67. package/dist/{heartbeat-60qtxtjf.js → heartbeat-vtesda5d.js} +20 -20
  68. package/dist/{hook-rbktcstg.js → hook-8wqaszda.js} +1 -1
  69. package/dist/{hook-ngtvh729.js → hook-b2hhj6rd.js} +1 -1
  70. package/dist/{http-z8xy72gp.js → http-mxcqhhcs.js} +2757 -2448
  71. package/dist/{index-a9nsavhx.js → index-1r8wfpzs.js} +1 -1
  72. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  73. package/dist/{index-s193j5fh.js → index-kw2mmat7.js} +20 -20
  74. package/dist/{index-06qkmcx6.js → index-n1vbvta9.js} +17 -9
  75. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  76. package/dist/{index-1cscynkb.js → index-spwxjgay.js} +17 -17
  77. package/dist/{index-r70q0xjx.js → index-srg522m6.js} +17 -17
  78. package/dist/{keepalive-a9srcphj.js → keepalive-dsd8rtgx.js} +3 -3
  79. package/dist/{lead-3devneqy.js → lead-zyerk1x2.js} +30 -30
  80. package/dist/{maintenance-0er16h2y.js → maintenance-jqmqwnet.js} +1 -1
  81. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  82. package/dist/{onboard-wqywz5py.js → onboard-w4yd7s8t.js} +2 -2
  83. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  84. package/dist/{otel-impl-vk32s884.js → otel-impl-f36byzbd.js} +1 -1
  85. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-xvak5p4v.js} +3 -3
  86. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-682k2mt5.js} +1 -1
  87. package/dist/{rbac-roles-452665y0.js → rbac-roles-gfn30fb8.js} +2 -2
  88. package/dist/{render-v2-0ttsk2j4.js → render-v2-hgytz8nn.js} +3 -3
  89. package/dist/{resume-a26kvr41.js → resume-2dvq348g.js} +7 -5
  90. package/dist/{session-access-dgh9nkwk.js → session-access-7f21ekq9.js} +4 -4
  91. package/dist/{setup-9vc9qahm.js → setup-epffxtbk.js} +1 -1
  92. package/dist/{work-objects-vn8nkxwn.js → work-objects-xz1a5rqk.js} +20 -20
  93. package/dist/{worker-1x1h8vnm.js → worker-42t8emjt.js} +30 -30
  94. package/openapi.json +3045 -2456
  95. package/package.json +4 -4
  96. package/src/be/approval-config-question.ts +389 -0
  97. package/src/be/automation-preflight.ts +37 -3
  98. package/src/be/db/session-tree.ts +73 -15
  99. package/src/be/db/skill-search.ts +87 -0
  100. package/src/be/db/tasks/read.ts +29 -2
  101. package/src/be/db/workflow-runs.ts +54 -7
  102. package/src/be/db.ts +25 -5
  103. package/src/be/migrations/239_skills_fts.sql +40 -0
  104. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  105. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  106. package/src/be/swarm-config-guard.ts +23 -0
  107. package/src/extensions/catalog.generated.json +4 -4
  108. package/src/http/approval-requests.ts +209 -38
  109. package/src/http/config.ts +22 -37
  110. package/src/http/sessions.ts +27 -2
  111. package/src/integrations/chat-sdk/inbound.ts +5 -4
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/dsh-adapter.ts +1 -1
  114. package/src/tests/approval-config-question.test.ts +702 -0
  115. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  116. package/src/tests/dsh-adapter.test.ts +1 -1
  117. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  118. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  119. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  120. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  121. package/src/tests/script-connections.test.ts +6 -0
  122. package/src/tests/sessions-tree.test.ts +168 -1
  123. package/src/tests/skill-search-long-content.test.ts +193 -0
  124. package/src/tests/skill-search.test.ts +173 -22
  125. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  126. package/src/tools/request-human-input.ts +27 -5
  127. package/src/types.ts +7 -0
  128. package/src/workflows/approval-notifications.ts +10 -3
  129. package/src/workflows/checkpoint.ts +69 -35
  130. package/src/workflows/engine.ts +234 -87
  131. package/src/workflows/executors/base.ts +6 -0
  132. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  133. package/src/workflows/executors/sub-workflow.ts +45 -16
  134. package/src/workflows/recovery.ts +63 -1
  135. package/src/workflows/resume.ts +60 -21
  136. package/src/workflows/retry-poller.ts +25 -4
  137. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  138. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  139. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  140. package/templates/skills/turso-interaction/content.md +1 -1
  141. package/templates/skills/workflow-iterate/content.md +8 -1
  142. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  143. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "2.5.2",
3
+ "version": "2.5.3",
4
4
  "description": "agent-swarm.dev — an open-source operating system for AI work: a lead agent breaks goals into tasks, routes them to specialized workers such as Claude Code or Codex, runs each worker in an isolated container, and preserves shared memory, tools, schedules, and review gates so delegated work compounds across sessions.",
5
5
  "keywords": [
6
6
  "agent-swarm.dev",
@@ -180,8 +180,8 @@
180
180
  "@desplega/model-catalog": "workspace:*",
181
181
  "@desplega/model-routing": "workspace:*",
182
182
  "@faker-js/faker": "^10.4.0",
183
- "@opencode/plugin": "2.1.3",
184
- "@opencode/sdk": "2.1.3",
183
+ "@opencode/plugin": "2.1.4",
184
+ "@opencode/sdk": "2.1.4",
185
185
  "@oxlint/plugins": "1.78.0",
186
186
  "@types/bun": "latest",
187
187
  "dependency-cruiser": "^18.0.0",
@@ -202,7 +202,7 @@
202
202
  "@agentclientprotocol/sdk": "2.0.1",
203
203
  "@ai-sdk/openai": "^3.0.41",
204
204
  "@anthropic-ai/claude-agent-sdk": "0.3.309",
205
- "@anthropic-ai/sdk": "^1.1.0",
205
+ "@anthropic-ai/sdk": "^1.2.0",
206
206
  "@asteasolutions/zod-to-openapi": "^8.0.0",
207
207
  "@aws-sdk/client-bedrock": "3.1048.0",
208
208
  "@chat-adapter/discord": "4.41.1",
@@ -0,0 +1,389 @@
1
+ /**
2
+ * HITL `config` questions: ask a person to set one swarm config key (a secret
3
+ * or a plain value) from the dashboard. The answer is written to
4
+ * `swarm_config` by the respond handler, and only a {@link ConfigResult} is
5
+ * ever stored on the request or sent onward, so a secret never reaches the
6
+ * agent, the workflow run, the follow-up task or any notification.
7
+ */
8
+ import * as z from "zod";
9
+ import { getDbClient } from "./db";
10
+ import { upsertSwarmConfigWithPolicyMirror } from "./multi-runtime";
11
+ import {
12
+ internalConfigKeyError,
13
+ isApiOnlyConfigKey,
14
+ isInternalConfigKey,
15
+ isReservedConfigKey,
16
+ reservedKeyError,
17
+ validateConfigEntry,
18
+ } from "./swarm-config-guard";
19
+
20
+ export const ConfigQuestionSettingsSchema = z.object({
21
+ key: z
22
+ .string()
23
+ .min(1)
24
+ .max(255)
25
+ .regex(/^\S+$/, "must not contain whitespace")
26
+ .describe("The swarm config key to set, e.g. BITBUCKET_TOKEN"),
27
+ scope: z.enum(["global", "agent", "repo"]).describe("Config scope the value is written to"),
28
+ scopeId: z
29
+ .string()
30
+ .min(1)
31
+ .optional()
32
+ .describe(
33
+ "Agent or repo id for agent/repo scope. From request-human-input, agent scope defaults to the calling agent; pass it explicitly when asking on behalf of another agent.",
34
+ ),
35
+ isSecret: z
36
+ .boolean()
37
+ .optional()
38
+ .describe(
39
+ "Store encrypted and never show the value (default true). An existing secret row always stays secret.",
40
+ ),
41
+ helpUrl: z
42
+ .string()
43
+ .url()
44
+ .refine((url) => /^https?:\/\//i.test(url), "must be an http(s) URL")
45
+ .optional()
46
+ .describe("Link shown next to the field, e.g. where to create the token"),
47
+ options: z
48
+ .array(
49
+ z.object({
50
+ value: z.string(),
51
+ label: z.string(),
52
+ description: z.string().optional(),
53
+ }),
54
+ )
55
+ .optional()
56
+ .describe("Allowed values for a non-secret key, rendered as a choice"),
57
+ defaultValue: z.string().optional().describe("Prefilled value for a non-secret key"),
58
+ });
59
+
60
+ export type ConfigQuestionSettings = z.infer<typeof ConfigQuestionSettingsSchema>;
61
+
62
+ /** Settings after create-time normalization: `isSecret` is always resolved. */
63
+ export type NormalizedConfigSettings = ConfigQuestionSettings & { isSecret: boolean };
64
+
65
+ /** What the dashboard sends for a config question (D6). */
66
+ export const ConfigAnswerSchema = z.discriminatedUnion("action", [
67
+ z.object({ action: z.literal("set"), value: z.string() }),
68
+ z.object({ action: z.literal("keep") }),
69
+ z.object({ action: z.literal("skip") }),
70
+ ]);
71
+ export type ConfigAnswer = z.infer<typeof ConfigAnswerSchema>;
72
+
73
+ /** The only form of a config answer that is ever stored or sent (D7). */
74
+ export const ConfigResultSchema = z.object({
75
+ key: z.string(),
76
+ scope: z.enum(["global", "agent", "repo"]),
77
+ scopeId: z.string().nullable(),
78
+ isSecret: z.boolean(),
79
+ status: z.enum(["set", "kept", "skipped"]),
80
+ updatedAt: z.string().nullable(),
81
+ value: z.string().optional(),
82
+ });
83
+ export type ConfigResult = z.infer<typeof ConfigResultSchema>;
84
+
85
+ /** Live state of the key a config question targets (never persisted). */
86
+ export const ConfigStateSchema = z.object({
87
+ exists: z.boolean(),
88
+ isSecret: z.boolean(),
89
+ updatedAt: z.string().nullable(),
90
+ value: z.string().optional().describe("Current value; present only for non-secret rows"),
91
+ });
92
+ export type ConfigState = z.infer<typeof ConfigStateSchema>;
93
+
94
+ type ConfigQuestionInput = {
95
+ id: string;
96
+ type: string;
97
+ label?: string;
98
+ required?: boolean;
99
+ config?: ConfigQuestionSettings;
100
+ };
101
+
102
+ export type NormalizedConfigQuestion<Q extends ConfigQuestionInput> = Omit<Q, "config"> & {
103
+ label: string;
104
+ required: boolean;
105
+ config: NormalizedConfigSettings;
106
+ };
107
+
108
+ type ApproversLike = { policy: unknown };
109
+
110
+ interface ConfigRowMeta {
111
+ isSecret: boolean;
112
+ updatedAt: string;
113
+ envPath: string | null;
114
+ description: string | null;
115
+ value?: string;
116
+ }
117
+
118
+ /** The exact `(scope, scopeId, key)` row, without decrypting anything. */
119
+ async function readConfigRow(
120
+ settings: Pick<ConfigQuestionSettings, "scope" | "scopeId" | "key">,
121
+ ): Promise<ConfigRowMeta | null> {
122
+ const scopeId = settings.scope === "global" ? null : (settings.scopeId ?? null);
123
+ const row = await getDbClient().get<{
124
+ isSecret: number;
125
+ lastUpdatedAt: string;
126
+ envPath: string | null;
127
+ description: string | null;
128
+ plainValue: string | null;
129
+ }>(
130
+ `SELECT isSecret, lastUpdatedAt, envPath, description,
131
+ CASE WHEN isSecret = 1 THEN NULL ELSE value END AS plainValue
132
+ FROM swarm_config
133
+ WHERE scope = ? AND ${scopeId === null ? "scopeId IS NULL" : "scopeId = ?"} AND key = ?`,
134
+ scopeId === null ? [settings.scope, settings.key] : [settings.scope, scopeId, settings.key],
135
+ );
136
+ if (!row) return null;
137
+ // API-only keys are never readable over HTTP, so they count as secret here.
138
+ const isSecret = row.isSecret === 1 || isApiOnlyConfigKey(settings.key);
139
+ return {
140
+ isSecret,
141
+ updatedAt: row.lastUpdatedAt,
142
+ envPath: row.envPath,
143
+ description: row.description,
144
+ ...(isSecret || row.plainValue === null ? {} : { value: row.plainValue }),
145
+ };
146
+ }
147
+
148
+ export async function readConfigState(settings: ConfigQuestionSettings): Promise<ConfigState> {
149
+ const row = await readConfigRow(settings);
150
+ if (!row) return { exists: false, isSecret: settings.isSecret !== false, updatedAt: null };
151
+ const isSecret = row.isSecret || settings.isSecret !== false;
152
+ return {
153
+ exists: true,
154
+ isSecret,
155
+ updatedAt: row.updatedAt,
156
+ ...(!isSecret && row.value !== undefined ? { value: row.value } : {}),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Create-time rules for one config question (D2, D3, D5). Defaults `label` to
162
+ * the key and `required` to true, resolves the effective `isSecret` against
163
+ * the live row, and rejects keys and shapes the config API would refuse.
164
+ */
165
+ export async function normalizeConfigQuestion<Q extends ConfigQuestionInput>(
166
+ question: Q,
167
+ ctx: { approvers: ApproversLike; callerAgentId?: string },
168
+ ): Promise<{ ok: true; question: NormalizedConfigQuestion<Q> } | { ok: false; error: string }> {
169
+ const fail = (reason: string) => ({
170
+ ok: false as const,
171
+ error: `config question "${question.id}": ${reason}`,
172
+ });
173
+ const parsed = ConfigQuestionSettingsSchema.safeParse(question.config);
174
+ if (!parsed.success) {
175
+ const issue = parsed.error.issues[0];
176
+ const path = issue?.path.length ? `config.${issue.path.map(String).join(".")}` : "config";
177
+ return fail(`${path}: ${issue?.message ?? "invalid"}`);
178
+ }
179
+ if (ctx.approvers.policy !== "any") {
180
+ return fail('a config question needs approvers.policy "any" (a config value has one author)');
181
+ }
182
+
183
+ const settings = { ...parsed.data };
184
+ if (settings.scope === "agent" && !settings.scopeId && ctx.callerAgentId) {
185
+ settings.scopeId = ctx.callerAgentId;
186
+ }
187
+ if (settings.scope === "global" && settings.scopeId) {
188
+ return fail("global scope must not have scopeId");
189
+ }
190
+ if (settings.scope !== "global" && !settings.scopeId) {
191
+ return fail(`${settings.scope} scope requires scopeId`);
192
+ }
193
+ if (isReservedConfigKey(settings.key)) return fail(reservedKeyError(settings.key).message);
194
+ if (isInternalConfigKey(settings.key)) return fail(internalConfigKeyError(settings.key).message);
195
+
196
+ const row = await readConfigRow(settings);
197
+ const isSecret =
198
+ settings.isSecret !== false || row?.isSecret === true || isApiOnlyConfigKey(settings.key);
199
+ if (isSecret && (settings.options || settings.defaultValue !== undefined)) {
200
+ return fail(
201
+ "options and defaultValue are only allowed when isSecret is false (and the key is not already stored as a secret)",
202
+ );
203
+ }
204
+ if (settings.options?.length === 0) return fail("options must not be empty");
205
+ if (
206
+ settings.options &&
207
+ settings.defaultValue !== undefined &&
208
+ !settings.options.some((option) => option.value === settings.defaultValue)
209
+ ) {
210
+ return fail("defaultValue must be one of options");
211
+ }
212
+
213
+ const label = question.label?.trim() ? question.label : settings.key;
214
+ return {
215
+ ok: true,
216
+ question: {
217
+ ...question,
218
+ label,
219
+ required: question.required ?? true,
220
+ config: { ...settings, isSecret },
221
+ },
222
+ };
223
+ }
224
+
225
+ type AnyQuestion = { id: string; type: string; label?: string; config?: ConfigQuestionSettings };
226
+
227
+ /**
228
+ * Normalizes every config question in a list and checks that the others carry
229
+ * a label and no `config` block. Non-config questions pass through unchanged.
230
+ */
231
+ export async function normalizeApprovalQuestions<Q extends AnyQuestion>(
232
+ questions: Q[],
233
+ ctx: { approvers: ApproversLike; callerAgentId?: string },
234
+ ): Promise<
235
+ | { ok: true; questions: ((Q & { label: string }) | NormalizedConfigQuestion<Q>)[] }
236
+ | { ok: false; error: string }
237
+ > {
238
+ const out: ((Q & { label: string }) | NormalizedConfigQuestion<Q>)[] = [];
239
+ for (const question of questions) {
240
+ if (question.type !== "config") {
241
+ if (question.config !== undefined) {
242
+ return {
243
+ ok: false,
244
+ error: `question "${question.id}": the config block is only valid on type "config"`,
245
+ };
246
+ }
247
+ if (typeof question.label !== "string") {
248
+ return { ok: false, error: `question "${question.id}": label is required` };
249
+ }
250
+ out.push({ ...question, label: question.label });
251
+ continue;
252
+ }
253
+ const normalized = await normalizeConfigQuestion(question, ctx);
254
+ if (!normalized.ok) return normalized;
255
+ out.push(normalized.question);
256
+ }
257
+ return { ok: true, questions: out };
258
+ }
259
+
260
+ /** A stored config question, as read back from `approval_requests.questions`. */
261
+ export type StoredConfigQuestion = {
262
+ id: string;
263
+ type: "config";
264
+ label: string;
265
+ required?: boolean;
266
+ config: NormalizedConfigSettings;
267
+ };
268
+
269
+ export function isConfigQuestion(question: {
270
+ type: string;
271
+ config?: unknown;
272
+ }): question is StoredConfigQuestion {
273
+ return question.type === "config" && typeof question.config === "object" && !!question.config;
274
+ }
275
+
276
+ /**
277
+ * Validates one raw answer against D6 and the config API's rules. Errors name
278
+ * the question and the rule, never the value.
279
+ */
280
+ export function parseConfigAnswer(
281
+ question: StoredConfigQuestion,
282
+ raw: unknown,
283
+ state: ConfigState,
284
+ ): { ok: true; answer: ConfigAnswer } | { ok: false; error: string } {
285
+ const fail = (reason: string) => ({
286
+ ok: false as const,
287
+ error: `config question "${question.id}": ${reason}`,
288
+ });
289
+ const required = question.required !== false;
290
+ if (raw === undefined || raw === null) {
291
+ return required ? fail("an answer is required") : { ok: true, answer: { action: "skip" } };
292
+ }
293
+ const parsed = ConfigAnswerSchema.safeParse(raw);
294
+ if (!parsed.success) {
295
+ return fail(
296
+ 'answer must be { action: "set", value } , { action: "keep" } or { action: "skip" }',
297
+ );
298
+ }
299
+ const answer = parsed.data;
300
+ if (answer.action === "keep") {
301
+ return state.exists ? { ok: true, answer } : fail("cannot keep a key that is not set");
302
+ }
303
+ if (answer.action === "skip") {
304
+ return required ? fail("a required question cannot be skipped") : { ok: true, answer };
305
+ }
306
+ if (answer.value.trim().length === 0) return fail("value must not be empty");
307
+ const { config } = question;
308
+ const isSecret = config.isSecret || state.isSecret;
309
+ if (!isSecret && config.options && !config.options.some((o) => o.value === answer.value)) {
310
+ return fail("value must be one of options");
311
+ }
312
+ const error = validateConfigEntry({
313
+ scope: config.scope,
314
+ scopeId: config.scopeId ?? null,
315
+ key: config.key,
316
+ value: answer.value,
317
+ });
318
+ if (error) return fail(isSecret ? `value rejected by ${config.key} validation` : error);
319
+ return { ok: true, answer };
320
+ }
321
+
322
+ function result(
323
+ question: StoredConfigQuestion,
324
+ status: ConfigResult["status"],
325
+ isSecret: boolean,
326
+ updatedAt: string | null,
327
+ value?: string,
328
+ ): ConfigResult {
329
+ const { key, scope, scopeId } = question.config;
330
+ return {
331
+ key,
332
+ scope,
333
+ scopeId: scope === "global" ? null : (scopeId ?? null),
334
+ isSecret,
335
+ status,
336
+ updatedAt,
337
+ ...(!isSecret && value !== undefined ? { value } : {}),
338
+ };
339
+ }
340
+
341
+ export function skippedConfigResult(question: StoredConfigQuestion): ConfigResult {
342
+ return result(question, "skipped", question.config.isSecret, null);
343
+ }
344
+
345
+ /** `keep`: nothing is written. Null when the row disappeared since validation. */
346
+ export async function keptConfigResult(
347
+ question: StoredConfigQuestion,
348
+ ): Promise<ConfigResult | null> {
349
+ const row = await readConfigRow(question.config);
350
+ if (!row) return null;
351
+ const isSecret = question.config.isSecret || row.isSecret;
352
+ return result(question, "kept", isSecret, row.updatedAt, row.value);
353
+ }
354
+
355
+ /**
356
+ * `set`: writes the value inside the caller's transaction. Carries the row's
357
+ * `envPath` and `description` forward (the upsert would null them) and keeps
358
+ * an existing secret secret. The caller runs the HTTP side effects after
359
+ * commit.
360
+ */
361
+ export async function applyConfigAnswer(
362
+ question: StoredConfigQuestion,
363
+ value: string,
364
+ ): Promise<ConfigResult> {
365
+ const { config } = question;
366
+ const existing = await readConfigRow(config);
367
+ const isSecret = config.isSecret || existing?.isSecret === true;
368
+ const written = await upsertSwarmConfigWithPolicyMirror({
369
+ scope: config.scope,
370
+ scopeId: config.scope === "global" ? null : (config.scopeId ?? null),
371
+ key: config.key,
372
+ value,
373
+ isSecret,
374
+ envPath: existing?.envPath ?? null,
375
+ description: existing?.description ?? null,
376
+ });
377
+ return result(question, "set", isSecret, written.lastUpdatedAt, value);
378
+ }
379
+
380
+ /** One line for the hitl-follow-up task text. Never carries a secret. */
381
+ export function formatConfigResult(value: unknown): string | null {
382
+ const parsed = ConfigResultSchema.safeParse(value);
383
+ if (!parsed.success) return null;
384
+ const r = parsed.data;
385
+ const where = `${r.scope}${r.scopeId ? `:${r.scopeId}` : ""}`;
386
+ const updated = r.updatedAt ? `, updated ${r.updatedAt}` : "";
387
+ const shown = !r.isSecret && r.value !== undefined ? ` = ${r.value}` : "";
388
+ return `${r.key} (${where}, ${r.isSecret ? "secret" : "plain"}): ${r.status}${updated}${shown}`;
389
+ }
@@ -345,6 +345,29 @@ function utcDayBounds(now: Date): { start: string; end: string } {
345
345
  return { start: start.toISOString(), end: new Date(start.getTime() + 86_400_000).toISOString() };
346
346
  }
347
347
 
348
+ /**
349
+ * True when `runId` is the workflow's only setup-failure run so far this UTC
350
+ * day, top-level or child. Call it in the transaction that inserted `runId`:
351
+ * transactions serialize on the write lock, so of several concurrent failures
352
+ * exactly one sees no other and alerts.
353
+ */
354
+ export async function isFirstWorkflowPreflightFailureToday(
355
+ workflowId: string,
356
+ runId: string,
357
+ now: Date = new Date(),
358
+ ): Promise<boolean> {
359
+ const bounds = utcDayBounds(now);
360
+ const row = await getDbClient().get<{ found: number }>(
361
+ `SELECT 1 AS found FROM workflow_runs
362
+ WHERE workflowId = ? AND id != ? AND status = 'failed'
363
+ AND error LIKE 'needs_setup:%'
364
+ AND startedAt >= ? AND startedAt < ?
365
+ LIMIT 1`,
366
+ [workflowId, runId, bounds.start, bounds.end],
367
+ );
368
+ return row == null;
369
+ }
370
+
348
371
  /** Record at most one copy of the same setup failure on a schedule per UTC day. */
349
372
  export async function recordSchedulePreflightFailure(
350
373
  scheduleId: string,
@@ -376,7 +399,11 @@ export async function recordSchedulePreflightFailure(
376
399
  });
377
400
  }
378
401
 
379
- /** Create and finish at most one failed preflight run per workflow and UTC day. */
402
+ /**
403
+ * Create and finish at most one failed top-level preflight run per workflow
404
+ * and UTC day. `recorded` is true when the caller should alert: this call
405
+ * inserted the run and no child failure run of the workflow came first today.
406
+ */
380
407
  export async function recordWorkflowPreflightFailure(input: {
381
408
  workflowId: string;
382
409
  triggerType: "schedule" | "manual" | "event" | "api";
@@ -388,9 +415,11 @@ export async function recordWorkflowPreflightFailure(input: {
388
415
  const now = input.now ?? new Date();
389
416
  const bounds = utcDayBounds(now);
390
417
  return await getDbClient().transaction(async (tx) => {
418
+ // Child runs record their own linked failure (`startWorkflowExecution`);
419
+ // a top-level trigger never reuses one.
391
420
  const existing = await tx.get<{ id: string }>(
392
421
  `SELECT id FROM workflow_runs
393
- WHERE workflowId = ? AND status = 'failed'
422
+ WHERE workflowId = ? AND status = 'failed' AND parentStepId IS NULL
394
423
  AND error LIKE 'needs_setup:%'
395
424
  AND startedAt >= ? AND startedAt < ?
396
425
  ORDER BY startedAt ASC LIMIT 1`,
@@ -417,6 +446,11 @@ export async function recordWorkflowPreflightFailure(input: {
417
446
  timestamp,
418
447
  ],
419
448
  );
420
- return { runId, recorded: true };
449
+ // Claimed in this transaction, which also sees every child failure run
450
+ // committed before it: concurrent failures alert exactly once a day.
451
+ return {
452
+ runId,
453
+ recorded: await isFirstWorkflowPreflightFailureToday(input.workflowId, runId, now),
454
+ };
421
455
  });
422
456
  }
@@ -16,8 +16,13 @@ export type SessionReadState = "unread" | "read" | "later" | "done";
16
16
  export interface SessionFilters {
17
17
  /** Restrict roots to these task sources. */
18
18
  source?: string[];
19
- /** Leave out roots of these task sources (the sessions v2 source filter). */
19
+ /** Leave out roots of these task sources (the older sessions v2 source filter). */
20
20
  hideSource?: string[];
21
+ /**
22
+ * Keep only roots of these task sources (the sessions v2 source filter).
23
+ * Unlike `source`, an empty list matches no root.
24
+ */
25
+ showSource?: string[];
21
26
  /** Match root task text or title with a case-insensitive substring. */
22
27
  q?: string;
23
28
  /** Match the root requester, excluding NULL requester rows. */
@@ -50,13 +55,17 @@ export interface SessionFilters {
50
55
 
51
56
  type SqlFragment = { sql: string; params: (string | number | null)[] };
52
57
 
53
- /** Shared read-state rule with bound ISO timestamps for the list and tree. */
58
+ /**
59
+ * Shared read-state rule with bound ISO timestamps for the list and tree.
60
+ * Done holds through agent activity (progress, status changes, child tasks):
61
+ * only `a.reopensDone`, counted in `sessionSummaryQuery`, brings it back.
62
+ */
54
63
  function sessionReadStateSql(now = new Date().toISOString()): SqlFragment {
55
64
  return {
56
65
  sql: `CASE
57
66
  WHEN s.id IS NULL THEN CASE WHEN a.lastActivityAt > ? THEN 'unread' ELSE 'read' END
58
67
  WHEN s.status = 'dismissed' THEN 'done'
59
- WHEN s.status = 'done' THEN CASE WHEN a.lastActivityAt > s.lastUpdatedAt THEN 'unread' ELSE 'done' END
68
+ WHEN s.status = 'done' THEN CASE WHEN a.reopensDone > 0 THEN 'unread' ELSE 'done' END
60
69
  WHEN s.status = 'snoozed' THEN CASE WHEN s.snoozeUntil > ? THEN 'later' ELSE 'unread' END
61
70
  WHEN a.lastActivityAt > COALESCE(s.lastReadAt, s.lastUpdatedAt) THEN 'unread'
62
71
  ELSE 'read'
@@ -100,6 +109,14 @@ export function sessionRootFilter(opts: SessionFilters = {}): SqlFragment {
100
109
  const hidden = opts.hideSource?.filter(Boolean) ?? [];
101
110
  if (hidden.length)
102
111
  add({ sql: `r.source NOT IN (${hidden.map(() => "?").join(",")})`, params: hidden });
112
+ if (opts.showSource) {
113
+ const shown = opts.showSource.filter(Boolean);
114
+ add(
115
+ shown.length
116
+ ? { sql: `r.source IN (${shown.map(() => "?").join(",")})`, params: shown }
117
+ : { sql: "0", params: [] },
118
+ );
119
+ }
103
120
  if (opts.q?.trim()) {
104
121
  const like = `%${opts.q.trim().toLowerCase()}%`;
105
122
  add({
@@ -152,6 +169,35 @@ function hasSessionChainFilter(opts: SessionFilters): boolean {
152
169
 
153
170
  const runningSql = `latestStatus NOT IN (${TERMINAL_TASK_STATUSES.map((s) => `'${s}'`).join(",")})`;
154
171
 
172
+ /**
173
+ * Sources of tasks that agents and the server create: delegation and scripts
174
+ * (`mcp`), follow-ups (`system`), deferred wake-ups (`schedule`) and workflow
175
+ * steps. Agents also set `requestedByUserId` explicitly on these, so a requester
176
+ * alone does not mark a human message.
177
+ */
178
+ const AGENT_TASK_SOURCES = ["mcp", "system", "schedule", "workflow"] as const;
179
+
180
+ /**
181
+ * Per chain row, over `chain` and the viewer's Done row `d`: whether the row
182
+ * needs the user since they marked the session Done. That is a human message
183
+ * (a task attributed to a person directly, not copied from its parent, from a
184
+ * human channel), a person's steering message on a chain task (steer or
185
+ * queue, not cancelled), or a pending approval, created after the Done mark.
186
+ * Steering `created_at` and the Done mark's `lastUpdatedAt` are both written
187
+ * as `%Y-%m-%dT%H:%M:%fZ`, so they compare as text.
188
+ */
189
+ const reopensDoneSql = `(d.id IS NOT NULL AND (
190
+ (chain.createdAt > d.lastUpdatedAt
191
+ AND chain.requestedByUserId IS NOT NULL
192
+ AND chain.requestedByUserIdInherited = 0
193
+ AND COALESCE(chain.source, '') NOT IN (${AGENT_TASK_SOURCES.map((s) => `'${s}'`).join(",")}))
194
+ OR EXISTS (SELECT 1 FROM task_steering_messages sm
195
+ WHERE sm.task_id = chain.id AND sm.created_by_kind = 'user'
196
+ AND sm.status != 'cancelled' AND sm.created_at > d.lastUpdatedAt)
197
+ OR EXISTS (SELECT 1 FROM approval_requests ar
198
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending' AND ar.createdAt > d.lastUpdatedAt)
199
+ ))`;
200
+
155
201
  /**
156
202
  * A session starts at a parentTaskId IS NULL root. One recursive CTE computes
157
203
  * chain-wide latest activity for list ordering. Root filters precede recursion.
@@ -162,7 +208,7 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
162
208
  const read = opts.viewer?.userId ? sessionReadStateSql(opts.now) : { sql: "NULL", params: [] };
163
209
  const params = [...root.params];
164
210
  if (opts.withUserId) params.push(opts.withUserId);
165
- params.push(...read.params, opts.viewer?.userId ?? null);
211
+ params.push(opts.viewer?.userId ?? null, ...read.params, opts.viewer?.userId ?? null);
166
212
  const filters: string[] = [];
167
213
  if (opts.triage === "unread") filters.push("readState = 'unread'");
168
214
  else if (opts.triage === "later" || opts.triage === "done") {
@@ -182,19 +228,27 @@ export function sessionSummaryQuery(opts: SessionFilters = {}): SqlFragment {
182
228
  return {
183
229
  // SQLite takes the bare status from the row with MAX(lastUpdatedAt).
184
230
  // Keep this as the only MIN/MAX aggregate so status follows activity.
185
- sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId) AS (
186
- SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId
231
+ // `d` is the viewer's Done row, so `reopensDone` can compare chain rows
232
+ // against the Done mark (SUM keeps MAX the only MIN/MAX aggregate).
233
+ sql: `WITH RECURSIVE chain(root_id, id, lastUpdatedAt, status, requestedByUserId,
234
+ createdAt, source, requestedByUserIdInherited) AS (
235
+ SELECT r.id, r.id, r.lastUpdatedAt, r.status, r.requestedByUserId,
236
+ r.createdAt, r.source, r.requestedByUserIdInherited
187
237
  FROM agent_tasks r WHERE ${root.sql}
188
238
  UNION ALL
189
- SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId
239
+ SELECT c.root_id, t.id, t.lastUpdatedAt, t.status, t.requestedByUserId,
240
+ t.createdAt, t.source, t.requestedByUserIdInherited
190
241
  FROM chain c JOIN agent_tasks t ON t.parentTaskId = c.id
191
242
  ), agg AS (
192
- SELECT root_id, COUNT(*) AS chainCount,
193
- status AS latestStatus, MAX(lastUpdatedAt) AS lastActivityAt,
194
- ${opts.withUserId ? "SUM(requestedByUserId = ?) AS withUser," : ""}
243
+ SELECT chain.root_id, COUNT(*) AS chainCount,
244
+ chain.status AS latestStatus, MAX(chain.lastUpdatedAt) AS lastActivityAt,
245
+ ${opts.withUserId ? "SUM(chain.requestedByUserId = ?) AS withUser," : ""}
195
246
  SUM(EXISTS (SELECT 1 FROM approval_requests ar
196
- WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval
197
- FROM chain GROUP BY root_id
247
+ WHERE ar.sourceTaskId = chain.id AND ar.status = 'pending')) AS pendingApproval,
248
+ SUM(${reopensDoneSql}) AS reopensDone
249
+ FROM chain LEFT JOIN inbox_item_state d
250
+ ON d.userId = ? AND d.itemType = 'to_read' AND d.itemId = chain.root_id AND d.status = 'done'
251
+ GROUP BY chain.root_id
198
252
  ), state AS (
199
253
  SELECT a.*, ${read.sql} AS readState, s.snoozeUntil
200
254
  FROM agg a LEFT JOIN inbox_item_state s
@@ -341,7 +395,7 @@ export interface SessionTree {
341
395
  export async function getSessionTree(
342
396
  actor: { kind: "bypass" } | { kind: "user"; userId: string | null },
343
397
  now?: string,
344
- opts: { mine?: boolean; hideSource?: string[] } = {},
398
+ opts: { mine?: boolean; hideSource?: string[]; showSource?: string[] } = {},
345
399
  ): Promise<SessionTree> {
346
400
  // No source filter in SQL: the source counts must include hidden sources,
347
401
  // so the filter menu can offer them back. The loop skips them instead.
@@ -373,6 +427,7 @@ export async function getSessionTree(
373
427
  sources: [],
374
428
  };
375
429
  const hidden = new Set(opts.hideSource);
430
+ const shown = opts.showSource ? new Set(opts.showSource) : null;
376
431
  const sources = new Map<string, number>();
377
432
  const groups = new Map<string, SessionTreeGroup>();
378
433
  const add = (section: SessionSectionCounts, row: (typeof rows)[number]) => {
@@ -383,7 +438,7 @@ export async function getSessionTree(
383
438
  };
384
439
  for (const row of rows) {
385
440
  sources.set(row.source, (sources.get(row.source) ?? 0) + 1);
386
- if (hidden.has(row.source)) continue;
441
+ if (hidden.has(row.source) || (shown && !shown.has(row.source))) continue;
387
442
  const key = sessionGroupKey(row.key);
388
443
  const noGroup =
389
444
  key === "shared/" || (actor.kind === "user" && key === `personal/${actor.userId}/`);
@@ -425,7 +480,10 @@ const UNREAD_CURSOR = "1970-01-01T00:00:00.000Z";
425
480
  */
426
481
  export async function triageSessionList(
427
482
  userId: string,
428
- filters: Pick<SessionFilters, "triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "now">,
483
+ filters: Pick<
484
+ SessionFilters,
485
+ "triage" | "noGroup" | "groupKey" | "mine" | "hideSource" | "showSource" | "now"
486
+ >,
429
487
  action: SessionListTriage,
430
488
  ): Promise<number> {
431
489
  const now = filters.now ?? new Date().toISOString();