@desplega.ai/agent-swarm 1.57.5 → 1.58.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.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.57.5",
5
+ "version": "1.58.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.57.5",
3
+ "version": "1.58.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -4,6 +4,7 @@ import {
4
4
  getAgentById,
5
5
  getAgentMailInboxMapping,
6
6
  getAllAgents,
7
+ resolveUser,
7
8
  } from "../be/db";
8
9
  import { resolveTemplate } from "../prompts/resolver";
9
10
  import { workflowEventBus } from "../workflows/event-bus";
@@ -11,6 +12,16 @@ import { workflowEventBus } from "../workflows/event-bus";
11
12
  import "./templates";
12
13
  import type { AgentMailMessage, AgentMailWebhookPayload } from "./types";
13
14
 
15
+ /**
16
+ * Extract bare email address from a from_ field like "Taras Yarema <t@desplega.ai>" or "t@desplega.ai".
17
+ */
18
+ function extractEmailFromField(from: string): string | undefined {
19
+ const angleMatch = from.match(/<([^>]+@[^>]+)>/);
20
+ if (angleMatch?.[1]) return angleMatch[1].toLowerCase();
21
+ const bareMatch = from.match(/[\w.+-]+@[\w.-]+\.\w+/);
22
+ return bareMatch?.[0]?.toLowerCase();
23
+ }
24
+
14
25
  /**
15
26
  * Check if an inbox domain is allowed by the filter.
16
27
  * Returns true if no filter is set or the inbox domain matches.
@@ -96,6 +107,10 @@ export async function handleMessageReceived(
96
107
  (Array.isArray(message.from_) ? message.from_.join(", ") : message.from_) || "unknown";
97
108
  const subject = message.subject || "(no subject)";
98
109
  const body = message.text || message.html || "";
110
+
111
+ // Resolve canonical user from sender email
112
+ const senderEmail = extractEmailFromField(from);
113
+ const requestedByUserId = senderEmail ? resolveUser({ email: senderEmail })?.id : undefined;
99
114
  const preview = body.length > 500 ? `${body.substring(0, 500)}...` : body;
100
115
 
101
116
  // Emit workflow trigger event
@@ -132,6 +147,7 @@ export async function handleMessageReceived(
132
147
  agentmailMessageId: message_id,
133
148
  agentmailThreadId: thread_id,
134
149
  parentTaskId: existingTask.id,
150
+ requestedByUserId,
135
151
  });
136
152
 
137
153
  console.log(
@@ -168,6 +184,7 @@ export async function handleMessageReceived(
168
184
  agentmailInboxId: inbox_id,
169
185
  agentmailMessageId: message_id,
170
186
  agentmailThreadId: thread_id,
187
+ requestedByUserId,
171
188
  });
172
189
 
173
190
  console.log(
@@ -196,6 +213,7 @@ export async function handleMessageReceived(
196
213
  agentmailInboxId: inbox_id,
197
214
  agentmailMessageId: message_id,
198
215
  agentmailThreadId: thread_id,
216
+ requestedByUserId,
199
217
  });
200
218
 
201
219
  console.log(
@@ -228,6 +246,7 @@ export async function handleMessageReceived(
228
246
  agentmailInboxId: inbox_id,
229
247
  agentmailMessageId: message_id,
230
248
  agentmailThreadId: thread_id,
249
+ requestedByUserId,
231
250
  });
232
251
 
233
252
  console.log(
@@ -255,6 +274,7 @@ export async function handleMessageReceived(
255
274
  agentmailInboxId: inbox_id,
256
275
  agentmailMessageId: message_id,
257
276
  agentmailThreadId: thread_id,
277
+ requestedByUserId,
258
278
  });
259
279
 
260
280
  console.log(`[AgentMail] Created unassigned task ${task.id} (no lead or mapping available)`);
package/src/be/db.ts CHANGED
@@ -44,6 +44,7 @@ import type {
44
44
  SwarmConfig,
45
45
  SwarmRepo,
46
46
  TriggerConfig,
47
+ User,
47
48
  VersionableField,
48
49
  VersionMeta,
49
50
  Workflow,
@@ -66,6 +67,17 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
66
67
  return db;
67
68
  }
68
69
 
70
+ // Fast path for tests: restore from pre-built template that already has
71
+ // migrations, seeds, and all post-init work baked in. Only the per-connection
72
+ // PRAGMA and the in-memory resolver function need to be set.
73
+ const templateBytes = (globalThis as any).__testMigrationTemplate as Uint8Array | undefined;
74
+ if (templateBytes) {
75
+ db = Database.deserialize(templateBytes);
76
+ db.run("PRAGMA foreign_keys = ON;");
77
+ configureDbResolver(resolvePromptTemplate);
78
+ return db;
79
+ }
80
+
69
81
  db = new Database(dbPath, { create: true });
70
82
  console.log(`Database initialized at ${dbPath}`);
71
83
 
@@ -730,6 +742,7 @@ type AgentTaskRow = {
730
742
  was_paused: number;
731
743
  credentialKeySuffix: string | null;
732
744
  credentialKeyType: string | null;
745
+ requestedByUserId: string | null;
733
746
  };
734
747
 
735
748
  function rowToAgentTask(row: AgentTaskRow): AgentTask {
@@ -785,6 +798,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
785
798
  wasPaused: !!row.was_paused,
786
799
  credentialKeySuffix: row.credentialKeySuffix ?? undefined,
787
800
  credentialKeyType: row.credentialKeyType ?? undefined,
801
+ requestedByUserId: row.requestedByUserId ?? undefined,
788
802
  };
789
803
  }
790
804
 
@@ -1798,6 +1812,7 @@ export interface CreateTaskOptions {
1798
1812
  workflowRunStepId?: string;
1799
1813
  sourceTaskId?: string;
1800
1814
  outputSchema?: Record<string, unknown>;
1815
+ requestedByUserId?: string;
1801
1816
  }
1802
1817
 
1803
1818
  /**
@@ -1865,6 +1880,9 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1865
1880
  if (parent.agentmailThreadId && !options.agentmailThreadId) {
1866
1881
  options.agentmailThreadId = parent.agentmailThreadId;
1867
1882
  }
1883
+ if (parent.requestedByUserId && !options.requestedByUserId) {
1884
+ options.requestedByUserId = parent.requestedByUserId;
1885
+ }
1868
1886
  }
1869
1887
  }
1870
1888
 
@@ -1889,8 +1907,8 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1889
1907
  vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
1890
1908
  agentmailInboxId, agentmailMessageId, agentmailThreadId,
1891
1909
  mentionMessageId, mentionChannelId, dir, parentTaskId, model, scheduleId,
1892
- workflowRunId, workflowRunStepId, outputSchema, createdAt, lastUpdatedAt
1893
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1910
+ workflowRunId, workflowRunStepId, outputSchema, requestedByUserId, createdAt, lastUpdatedAt
1911
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1894
1912
  )
1895
1913
  .get(
1896
1914
  id,
@@ -1927,6 +1945,7 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1927
1945
  options?.workflowRunId ?? null,
1928
1946
  options?.workflowRunStepId ?? null,
1929
1947
  options?.outputSchema ? JSON.stringify(options.outputSchema) : null,
1948
+ options?.requestedByUserId ?? null,
1930
1949
  now,
1931
1950
  now,
1932
1951
  );
@@ -7685,3 +7704,247 @@ export function getKeyCostSummary(keyType?: string): KeyCostSummary[] {
7685
7704
  )
7686
7705
  .all(...params);
7687
7706
  }
7707
+
7708
+ // ============================================================================
7709
+ // User Identity Operations
7710
+ // ============================================================================
7711
+
7712
+ type UserRow = {
7713
+ id: string;
7714
+ name: string;
7715
+ email: string | null;
7716
+ role: string | null;
7717
+ notes: string | null;
7718
+ slackUserId: string | null;
7719
+ linearUserId: string | null;
7720
+ githubUsername: string | null;
7721
+ gitlabUsername: string | null;
7722
+ emailAliases: string | null;
7723
+ preferredChannel: string | null;
7724
+ timezone: string | null;
7725
+ createdAt: string;
7726
+ lastUpdatedAt: string;
7727
+ };
7728
+
7729
+ function rowToUser(row: UserRow): User {
7730
+ return {
7731
+ id: row.id,
7732
+ name: row.name,
7733
+ email: row.email ?? undefined,
7734
+ role: row.role ?? undefined,
7735
+ notes: row.notes ?? undefined,
7736
+ slackUserId: row.slackUserId ?? undefined,
7737
+ linearUserId: row.linearUserId ?? undefined,
7738
+ githubUsername: row.githubUsername ?? undefined,
7739
+ gitlabUsername: row.gitlabUsername ?? undefined,
7740
+ emailAliases: row.emailAliases ? JSON.parse(row.emailAliases) : [],
7741
+ preferredChannel: row.preferredChannel ?? "slack",
7742
+ timezone: row.timezone ?? undefined,
7743
+ createdAt: row.createdAt,
7744
+ lastUpdatedAt: row.lastUpdatedAt,
7745
+ };
7746
+ }
7747
+
7748
+ /**
7749
+ * Resolve a user by any platform-specific identifier.
7750
+ * Priority: exact match on platform ID, then email (including aliases), then name substring.
7751
+ */
7752
+ export function resolveUser(opts: {
7753
+ slackUserId?: string;
7754
+ linearUserId?: string;
7755
+ githubUsername?: string;
7756
+ gitlabUsername?: string;
7757
+ email?: string;
7758
+ name?: string;
7759
+ }): User | null {
7760
+ const db = getDb();
7761
+
7762
+ // Try exact platform ID matches first
7763
+ if (opts.slackUserId) {
7764
+ const row = db
7765
+ .prepare<UserRow, string>("SELECT * FROM users WHERE slackUserId = ?")
7766
+ .get(opts.slackUserId);
7767
+ if (row) return rowToUser(row);
7768
+ }
7769
+ if (opts.linearUserId) {
7770
+ const row = db
7771
+ .prepare<UserRow, string>("SELECT * FROM users WHERE linearUserId = ?")
7772
+ .get(opts.linearUserId);
7773
+ if (row) return rowToUser(row);
7774
+ }
7775
+ if (opts.githubUsername) {
7776
+ const row = db
7777
+ .prepare<UserRow, string>("SELECT * FROM users WHERE githubUsername = ?")
7778
+ .get(opts.githubUsername);
7779
+ if (row) return rowToUser(row);
7780
+ }
7781
+ if (opts.gitlabUsername) {
7782
+ const row = db
7783
+ .prepare<UserRow, string>("SELECT * FROM users WHERE gitlabUsername = ?")
7784
+ .get(opts.gitlabUsername);
7785
+ if (row) return rowToUser(row);
7786
+ }
7787
+
7788
+ // Try email match (primary email)
7789
+ if (opts.email) {
7790
+ const row = db.prepare<UserRow, string>("SELECT * FROM users WHERE email = ?").get(opts.email);
7791
+ if (row) return rowToUser(row);
7792
+
7793
+ // Check emailAliases (JSON array search)
7794
+ const aliasRows = db
7795
+ .prepare<UserRow, []>("SELECT * FROM users WHERE emailAliases != '[]'")
7796
+ .all();
7797
+ for (const r of aliasRows) {
7798
+ const aliases: string[] = r.emailAliases ? JSON.parse(r.emailAliases) : [];
7799
+ if (aliases.some((a) => a.toLowerCase() === opts.email!.toLowerCase())) {
7800
+ return rowToUser(r);
7801
+ }
7802
+ }
7803
+ }
7804
+
7805
+ // Try name substring match (case-insensitive)
7806
+ if (opts.name) {
7807
+ const row = db
7808
+ .prepare<UserRow, string>("SELECT * FROM users WHERE LOWER(name) LIKE '%' || LOWER(?) || '%'")
7809
+ .get(opts.name);
7810
+ if (row) return rowToUser(row);
7811
+ }
7812
+
7813
+ return null;
7814
+ }
7815
+
7816
+ export function getUserById(id: string): User | null {
7817
+ const row = getDb().prepare<UserRow, string>("SELECT * FROM users WHERE id = ?").get(id);
7818
+ return row ? rowToUser(row) : null;
7819
+ }
7820
+
7821
+ export function getAllUsers(): User[] {
7822
+ return getDb().prepare<UserRow, []>("SELECT * FROM users ORDER BY name").all().map(rowToUser);
7823
+ }
7824
+
7825
+ export function createUser(data: {
7826
+ name: string;
7827
+ email?: string;
7828
+ role?: string;
7829
+ notes?: string;
7830
+ slackUserId?: string;
7831
+ linearUserId?: string;
7832
+ githubUsername?: string;
7833
+ gitlabUsername?: string;
7834
+ emailAliases?: string[];
7835
+ preferredChannel?: string;
7836
+ timezone?: string;
7837
+ }): User {
7838
+ const id = crypto.randomUUID().replace(/-/g, "");
7839
+ const now = new Date().toISOString();
7840
+ const row = getDb()
7841
+ .prepare<UserRow, (string | null)[]>(
7842
+ `INSERT INTO users (id, name, email, role, notes, slackUserId, linearUserId, githubUsername, gitlabUsername, emailAliases, preferredChannel, timezone, createdAt, lastUpdatedAt)
7843
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7844
+ )
7845
+ .get(
7846
+ id,
7847
+ data.name,
7848
+ data.email ?? null,
7849
+ data.role ?? null,
7850
+ data.notes ?? null,
7851
+ data.slackUserId ?? null,
7852
+ data.linearUserId ?? null,
7853
+ data.githubUsername ?? null,
7854
+ data.gitlabUsername ?? null,
7855
+ JSON.stringify(data.emailAliases ?? []),
7856
+ data.preferredChannel ?? "slack",
7857
+ data.timezone ?? null,
7858
+ now,
7859
+ now,
7860
+ );
7861
+ if (!row) throw new Error("Failed to create user");
7862
+ return rowToUser(row);
7863
+ }
7864
+
7865
+ export function updateUser(
7866
+ id: string,
7867
+ data: Partial<{
7868
+ name: string;
7869
+ email: string;
7870
+ role: string;
7871
+ notes: string;
7872
+ slackUserId: string;
7873
+ linearUserId: string;
7874
+ githubUsername: string;
7875
+ gitlabUsername: string;
7876
+ emailAliases: string[];
7877
+ preferredChannel: string;
7878
+ timezone: string;
7879
+ }>,
7880
+ ): User | null {
7881
+ const sets: string[] = [];
7882
+ const params: (string | null)[] = [];
7883
+
7884
+ if (data.name !== undefined) {
7885
+ sets.push("name = ?");
7886
+ params.push(data.name);
7887
+ }
7888
+ if (data.email !== undefined) {
7889
+ sets.push("email = ?");
7890
+ params.push(data.email);
7891
+ }
7892
+ if (data.role !== undefined) {
7893
+ sets.push("role = ?");
7894
+ params.push(data.role);
7895
+ }
7896
+ if (data.notes !== undefined) {
7897
+ sets.push("notes = ?");
7898
+ params.push(data.notes);
7899
+ }
7900
+ if (data.slackUserId !== undefined) {
7901
+ sets.push("slackUserId = ?");
7902
+ params.push(data.slackUserId);
7903
+ }
7904
+ if (data.linearUserId !== undefined) {
7905
+ sets.push("linearUserId = ?");
7906
+ params.push(data.linearUserId);
7907
+ }
7908
+ if (data.githubUsername !== undefined) {
7909
+ sets.push("githubUsername = ?");
7910
+ params.push(data.githubUsername);
7911
+ }
7912
+ if (data.gitlabUsername !== undefined) {
7913
+ sets.push("gitlabUsername = ?");
7914
+ params.push(data.gitlabUsername);
7915
+ }
7916
+ if (data.emailAliases !== undefined) {
7917
+ sets.push("emailAliases = ?");
7918
+ params.push(JSON.stringify(data.emailAliases));
7919
+ }
7920
+ if (data.preferredChannel !== undefined) {
7921
+ sets.push("preferredChannel = ?");
7922
+ params.push(data.preferredChannel);
7923
+ }
7924
+ if (data.timezone !== undefined) {
7925
+ sets.push("timezone = ?");
7926
+ params.push(data.timezone);
7927
+ }
7928
+
7929
+ if (sets.length === 0) return getUserById(id);
7930
+
7931
+ sets.push("lastUpdatedAt = ?");
7932
+ params.push(new Date().toISOString());
7933
+ params.push(id);
7934
+
7935
+ const row = getDb()
7936
+ .prepare<UserRow, (string | null)[]>(
7937
+ `UPDATE users SET ${sets.join(", ")} WHERE id = ? RETURNING *`,
7938
+ )
7939
+ .get(...params);
7940
+ return row ? rowToUser(row) : null;
7941
+ }
7942
+
7943
+ export function deleteUser(id: string): boolean {
7944
+ // Clear any task references before deleting
7945
+ getDb()
7946
+ .prepare("UPDATE agent_tasks SET requestedByUserId = NULL WHERE requestedByUserId = ?")
7947
+ .run(id);
7948
+ const result = getDb().prepare("DELETE FROM users WHERE id = ?").run(id);
7949
+ return result.changes > 0;
7950
+ }
@@ -0,0 +1,35 @@
1
+ -- User registry: canonical user profiles for cross-channel identity resolution
2
+ CREATE TABLE IF NOT EXISTS users (
3
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
4
+ name TEXT NOT NULL,
5
+ email TEXT,
6
+ role TEXT,
7
+ notes TEXT,
8
+ slackUserId TEXT UNIQUE,
9
+ linearUserId TEXT UNIQUE,
10
+ githubUsername TEXT UNIQUE,
11
+ gitlabUsername TEXT UNIQUE,
12
+ emailAliases TEXT DEFAULT '[]',
13
+ preferredChannel TEXT DEFAULT 'slack',
14
+ timezone TEXT,
15
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
16
+ lastUpdatedAt TEXT NOT NULL DEFAULT (datetime('now'))
17
+ );
18
+
19
+ -- Reverse lookup indexes (partial — only index non-null values)
20
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_slack ON users(slackUserId) WHERE slackUserId IS NOT NULL;
21
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_linear ON users(linearUserId) WHERE linearUserId IS NOT NULL;
22
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_github ON users(githubUsername) WHERE githubUsername IS NOT NULL;
23
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_gitlab ON users(gitlabUsername) WHERE gitlabUsername IS NOT NULL;
24
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
25
+
26
+ -- Link tasks to canonical users
27
+ ALTER TABLE agent_tasks ADD COLUMN requestedByUserId TEXT REFERENCES users(id);
28
+ CREATE INDEX IF NOT EXISTS idx_tasks_requested_by ON agent_tasks(requestedByUserId) WHERE requestedByUserId IS NOT NULL;
29
+
30
+ -- Seed initial users
31
+ INSERT OR IGNORE INTO users (id, name, email, role, slackUserId, githubUsername)
32
+ VALUES (lower(hex(randomblob(16))), 'Taras', 't@desplega.ai', 'founder', 'U08NR6QD6CS', 'tarasyarema');
33
+
34
+ INSERT OR IGNORE INTO users (id, name, email, role, slackUserId)
35
+ VALUES (lower(hex(randomblob(16))), 'Eze', 'e@desplega.ai', 'founder', 'U08NY4B5R2M');
@@ -1222,6 +1222,7 @@ interface Trigger {
1222
1222
  text?: string;
1223
1223
  }>;
1224
1224
  cursorUpdates?: Array<{ channelId: string; ts: string }>; // Deferred cursor commits for channel_activity
1225
+ requestedBy?: { name: string; email?: string };
1225
1226
  }
1226
1227
 
1227
1228
  /** Options for polling */
@@ -1339,10 +1340,16 @@ async function buildPromptForTrigger(
1339
1340
  '\n\nWhen done, use `store-progress` with status: "completed" and include your output.';
1340
1341
  }
1341
1342
 
1343
+ // Include requesting user info if available from the poll trigger
1344
+ const requestedBy = trigger.requestedBy;
1345
+ const requestedBySection = requestedBy
1346
+ ? `\n\nRequested by: ${requestedBy.name}${requestedBy.email ? ` (${requestedBy.email})` : ""}`
1347
+ : "";
1348
+
1342
1349
  const result = await resolveTemplateAsync("task.trigger.assigned", {
1343
1350
  work_on_task_cmd: fmt("work-on-task"),
1344
1351
  task_id: trigger.taskId,
1345
- task_desc_section: taskDescSection,
1352
+ task_desc_section: taskDescSection + requestedBySection,
1346
1353
  output_instructions: outputInstructions,
1347
1354
  });
1348
1355
  return result.text;
@@ -1,4 +1,4 @@
1
- import { createTaskExtended, failTask, findTaskByVcs, getAllAgents } from "../be/db";
1
+ import { createTaskExtended, failTask, findTaskByVcs, getAllAgents, resolveUser } from "../be/db";
2
2
  import { resolveTemplate } from "../prompts/resolver";
3
3
  import {
4
4
  detectMention,
@@ -134,6 +134,9 @@ export async function handlePullRequest(
134
134
  requested_reviewer,
135
135
  } = event;
136
136
 
137
+ // Resolve canonical user from GitHub sender
138
+ const requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
139
+
137
140
  // Handle assigned action - bot was assigned to PR
138
141
  if (action === "assigned") {
139
142
  // Check if bot was assigned
@@ -178,6 +181,7 @@ export async function handlePullRequest(
178
181
  vcsEventType: "pull_request",
179
182
  vcsNumber: pr.number,
180
183
  vcsAuthor: sender.login,
184
+ requestedByUserId,
181
185
  vcsUrl: pr.html_url,
182
186
  });
183
187
 
@@ -275,6 +279,7 @@ export async function handlePullRequest(
275
279
  vcsEventType: "pull_request",
276
280
  vcsNumber: pr.number,
277
281
  vcsAuthor: sender.login,
282
+ requestedByUserId,
278
283
  vcsUrl: pr.html_url,
279
284
  });
280
285
 
@@ -364,6 +369,7 @@ export async function handlePullRequest(
364
369
  vcsEventType: "pull_request",
365
370
  vcsNumber: pr.number,
366
371
  vcsAuthor: sender.login,
372
+ requestedByUserId,
367
373
  vcsUrl: pr.html_url,
368
374
  });
369
375
 
@@ -478,6 +484,9 @@ export async function handleIssue(
478
484
  ): Promise<{ created: boolean; taskId?: string }> {
479
485
  const { action, issue, repository, sender, installation, assignee } = event;
480
486
 
487
+ // Resolve canonical user from GitHub sender
488
+ const requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
489
+
481
490
  // Handle assigned action - bot was assigned to issue
482
491
  if (action === "assigned") {
483
492
  // Check if bot was assigned
@@ -520,6 +529,7 @@ export async function handleIssue(
520
529
  vcsEventType: "issues",
521
530
  vcsNumber: issue.number,
522
531
  vcsAuthor: sender.login,
532
+ requestedByUserId,
523
533
  vcsUrl: issue.html_url,
524
534
  });
525
535
 
@@ -605,6 +615,7 @@ export async function handleIssue(
605
615
  vcsEventType: "issues",
606
616
  vcsNumber: issue.number,
607
617
  vcsAuthor: sender.login,
618
+ requestedByUserId,
608
619
  vcsUrl: issue.html_url,
609
620
  });
610
621
 
@@ -702,6 +713,9 @@ export async function handleComment(
702
713
  ): Promise<{ created: boolean; taskId?: string }> {
703
714
  const { action, comment, repository, sender, issue, pull_request, installation } = event;
704
715
 
716
+ // Resolve canonical user from GitHub sender
717
+ const requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
718
+
705
719
  // Only handle created action
706
720
  if (action !== "created") {
707
721
  return { created: false };
@@ -802,6 +816,9 @@ export async function handlePullRequestReview(
802
816
  ): Promise<{ created: boolean; taskId?: string }> {
803
817
  const { action, review, pull_request: pr, repository, sender, installation } = event;
804
818
 
819
+ // Resolve canonical user from GitHub sender
820
+ const requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
821
+
805
822
  // Only handle submitted reviews (the most important action)
806
823
  // Edited reviews are less common and dismissed is handled by the state
807
824
  if (action !== "submitted") {
@@ -8,7 +8,7 @@
8
8
  * - Detects bot mentions
9
9
  */
10
10
 
11
- import { createTaskExtended, failTask, findTaskByVcs, getAllAgents } from "../be/db";
11
+ import { createTaskExtended, failTask, findTaskByVcs, getAllAgents, resolveUser } from "../be/db";
12
12
  import { resolveTemplate } from "../prompts/resolver";
13
13
  import { GITLAB_BOT_NAME } from "./auth";
14
14
  import { addGitLabNoteReaction, addGitLabReaction } from "./reactions";
@@ -60,6 +60,9 @@ export async function handleMergeRequest(
60
60
  const action = mr.action;
61
61
  const repo = project.path_with_namespace;
62
62
 
63
+ // Resolve canonical user from GitLab sender
64
+ const requestedByUserId = resolveUser({ gitlabUsername: user.username })?.id;
65
+
63
66
  console.log(`[GitLab] MR #${mr.iid} ${action} by ${user.username} in ${repo}`);
64
67
 
65
68
  const dedupKey = `gitlab-mr-${repo}-${mr.iid}-${action}-${user.username}`;
@@ -105,6 +108,7 @@ export async function handleMergeRequest(
105
108
  vcsEventType: "merge_request",
106
109
  vcsNumber: mr.iid,
107
110
  vcsAuthor: user.username,
111
+ requestedByUserId,
108
112
  vcsUrl: mr.url,
109
113
  });
110
114
 
@@ -151,6 +155,9 @@ export async function handleIssue(
151
155
  const action = issue.action;
152
156
  const repo = project.path_with_namespace;
153
157
 
158
+ // Resolve canonical user from GitLab sender
159
+ const requestedByUserId = resolveUser({ gitlabUsername: user.username })?.id;
160
+
154
161
  console.log(`[GitLab] Issue #${issue.iid} ${action} by ${user.username} in ${repo}`);
155
162
 
156
163
  const dedupKey = `gitlab-issue-${repo}-${issue.iid}-${action}-${user.username}`;
@@ -198,6 +205,7 @@ export async function handleIssue(
198
205
  vcsEventType: "issue",
199
206
  vcsNumber: issue.iid,
200
207
  vcsAuthor: user.username,
208
+ requestedByUserId,
201
209
  vcsUrl: issue.url,
202
210
  });
203
211
 
@@ -226,6 +234,9 @@ export async function handleNote(event: NoteEvent): Promise<{ created: boolean;
226
234
  const { user, project, object_attributes: note } = event;
227
235
  const repo = project.path_with_namespace;
228
236
 
237
+ // Resolve canonical user from GitLab sender
238
+ const requestedByUserId = resolveUser({ gitlabUsername: user.username })?.id;
239
+
229
240
  // Only handle comments with bot mentions
230
241
  if (!detectMention(note.note)) {
231
242
  return { created: false };
package/src/http/poll.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getOfferedTasksForAgent,
13
13
  getPendingTaskForAgent,
14
14
  getUnassignedTaskIds,
15
+ getUserById,
15
16
  hasCapacity,
16
17
  startTask,
17
18
  upsertChannelActivityCursor,
@@ -144,11 +145,19 @@ export async function handlePoll(
144
145
  conditions: [{ timeout_ms: 300_000 }], // 5 min: polling interval + queue wait
145
146
  });
146
147
 
148
+ // Resolve requesting user if available
149
+ const requestedByUser = pendingTask.requestedByUserId
150
+ ? getUserById(pendingTask.requestedByUserId)
151
+ : undefined;
152
+
147
153
  return {
148
154
  trigger: {
149
155
  type: "task_assigned",
150
156
  taskId: pendingTask.id,
151
157
  task: { ...pendingTask, status: "in_progress" },
158
+ ...(requestedByUser && {
159
+ requestedBy: { name: requestedByUser.name, email: requestedByUser.email },
160
+ }),
152
161
  },
153
162
  };
154
163
  }