glab-agent 0.2.5 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glab-agent",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "description": "Multi-agent GitLab To-Do watcher with YAML-defined agents, skills, and GitLab registry.",
6
6
  "license": "MIT",
@@ -31,6 +31,10 @@ export interface AgentGitlabConfig {
31
31
 
32
32
  export interface AgentNotifications {
33
33
  webhook_url?: string;
34
+ feishu_app_id?: string;
35
+ feishu_app_secret?: string;
36
+ /** Email domain for mapping GitLab username → Feishu email (e.g. "taou.com" → "{username}@taou.com") */
37
+ feishu_email_domain?: string;
34
38
  }
35
39
 
36
40
  export interface AgentLabelConfig {
@@ -126,7 +130,10 @@ function parseNotifications(raw: unknown): AgentNotifications {
126
130
  if (typeof raw !== "object" || raw === null) return {};
127
131
  const obj = raw as RawValue;
128
132
  return {
129
- webhook_url: typeof obj.webhook_url === "string" ? obj.webhook_url : undefined
133
+ webhook_url: typeof obj.webhook_url === "string" ? obj.webhook_url : undefined,
134
+ feishu_app_id: typeof obj.feishu_app_id === "string" ? obj.feishu_app_id : undefined,
135
+ feishu_app_secret: typeof obj.feishu_app_secret === "string" ? obj.feishu_app_secret : undefined,
136
+ feishu_email_domain: typeof obj.feishu_email_domain === "string" ? obj.feishu_email_domain : undefined,
130
137
  };
131
138
  }
132
139
 
@@ -24,10 +24,54 @@ export interface AgentRunContext {
24
24
  signal?: AbortSignal;
25
25
  }
26
26
 
27
+ export interface SpawnedRunResult {
28
+ exitCode: number | null;
29
+ summary: string;
30
+ outputPath: string;
31
+ /** Set if non-zero exit */
32
+ error?: string;
33
+ }
34
+
35
+ export interface AgentProgressEvent {
36
+ type: "tool_use" | "tool_result" | "text" | "status";
37
+ tool?: string; // Read, Edit, Write, Bash, Grep, Glob, etc.
38
+ detail?: string; // file path, command, text snippet
39
+ timestamp: string;
40
+ }
41
+
42
+ export type ProgressCallback = (event: AgentProgressEvent) => void;
43
+
44
+ export interface SpawnedRun {
45
+ pid: number;
46
+ outputPath: string;
47
+ /** Called to check if process has exited. Returns result if done, undefined if still running. */
48
+ poll(): Promise<SpawnedRunResult | undefined>;
49
+ /** Kill the process */
50
+ kill(): void;
51
+ /** Set a callback to receive real-time progress events (stream-json mode) */
52
+ onProgress?: ProgressCallback;
53
+ }
54
+
27
55
  export interface AgentRunner {
28
56
  run(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<AgentRunResult>;
29
57
  /** Run a lightweight contextual reply — no worktree, read-only. Optional: falls back to full_work if absent. */
30
58
  runContextual?(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult>;
59
+ /** Start the agent process without waiting for it to finish. Returns a handle for polling. */
60
+ spawn?(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<SpawnedRun>;
61
+ }
62
+
63
+ /**
64
+ * Wrap a completed run() result as a SpawnedRun (for smoke tests or fallback).
65
+ */
66
+ export function createCompletedSpawnedRun(result: AgentRunResult): SpawnedRun {
67
+ return {
68
+ pid: process.pid,
69
+ outputPath: result.outputPath ?? "",
70
+ async poll(): Promise<SpawnedRunResult> {
71
+ return { exitCode: 0, summary: result.summary, outputPath: result.outputPath ?? "" };
72
+ },
73
+ kill() { /* no-op, already completed */ }
74
+ };
31
75
  }
32
76
 
33
77
  export interface AgentRunnerOptions {
@@ -1,5 +1,7 @@
1
- import { execFile } from "node:child_process";
2
- import { writeFile } from "node:fs/promises";
1
+ import { execFile, spawn as spawnChild } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { createWriteStream } from "node:fs";
4
+ import { readFile, writeFile } from "node:fs/promises";
3
5
  import { promisify } from "node:util";
4
6
 
5
7
  import { compactText, truncate } from "../text.js";
@@ -11,8 +13,12 @@ import {
11
13
  type AgentRunResult,
12
14
  type AgentRunner,
13
15
  type AgentRunnerOptions,
16
+ type ProgressCallback,
17
+ type SpawnedRun,
18
+ type SpawnedRunResult,
14
19
  buildAgentPrompt,
15
20
  buildContextualPrompt,
21
+ createCompletedSpawnedRun,
16
22
  createOutputPath,
17
23
  isSmokeIssue,
18
24
  runSmokeTestChecks
@@ -132,6 +138,187 @@ export class ClaudeRunner implements AgentRunner {
132
138
  }
133
139
  }
134
140
 
141
+ async spawn(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<SpawnedRun> {
142
+ const outputPath = await createOutputPath("claude", issue.iid);
143
+
144
+ if (isSmokeIssue(issue)) {
145
+ const result = await this.run(issue, worktree, context);
146
+ return createCompletedSpawnedRun(result);
147
+ }
148
+
149
+ const prompt = buildAgentPrompt({
150
+ issueIid: issue.iid,
151
+ issueTitle: issue.title,
152
+ gitlabHost: this.gitlabHost,
153
+ gitlabProjectId: this.gitlabProjectId,
154
+ worktreePath: worktree.worktreePath,
155
+ branch: worktree.branch,
156
+ provider: "claude",
157
+ todoId: context?.todoId,
158
+ preamble: this.agentDefinition?.prompt.preamble,
159
+ append: this.agentDefinition?.prompt.append,
160
+ labels: this.agentDefinition?.labels,
161
+ });
162
+ const claudeCommand = resolveClaudeCommand(this.env);
163
+
164
+ const args = ["-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"];
165
+ if (this.agentDefinition?.model) {
166
+ args.push("--model", this.agentDefinition.model);
167
+ }
168
+ args.push(prompt);
169
+
170
+ this.logger.info(`Spawning: ${claudeCommand} -p ... cwd=${worktree.worktreePath}`);
171
+ this.logger.info(`Prompt (${prompt.length} chars): issue #${issue.iid} "${issue.title}"`);
172
+
173
+ const outStream = createWriteStream(outputPath);
174
+ const child = spawnChild(claudeCommand, args, {
175
+ cwd: worktree.worktreePath,
176
+ env: this.env,
177
+ stdio: ["ignore", "pipe", "pipe"],
178
+ detached: false,
179
+ });
180
+
181
+ let progressCallback: ProgressCallback | undefined;
182
+ let lastResult = ""; // capture the final result text
183
+
184
+ const rl = createInterface({ input: child.stdout!, crlfDelay: Infinity });
185
+
186
+ rl.on("line", (line: string) => {
187
+ // Write raw line to output file for debugging
188
+ outStream.write(line + "\n");
189
+
190
+ try {
191
+ const event = JSON.parse(line) as Record<string, unknown>;
192
+
193
+ // Extract final result
194
+ if (event.type === "result" && typeof event.result === "string") {
195
+ lastResult = event.result;
196
+ }
197
+
198
+ // Parse progress events
199
+ if (event.type === "assistant" && progressCallback) {
200
+ const message = event.message as Record<string, unknown> | undefined;
201
+ const content = message?.content as Array<Record<string, unknown>> | undefined;
202
+ if (Array.isArray(content)) {
203
+ for (const block of content) {
204
+ if (block.type === "tool_use") {
205
+ const toolName = String(block.name ?? "");
206
+ const input = block.input as Record<string, unknown> | undefined;
207
+ let detail = "";
208
+
209
+ if (toolName === "Read" || toolName === "Edit" || toolName === "Write") {
210
+ detail = String(input?.file_path ?? "").split("/").pop() ?? "";
211
+ } else if (toolName === "Bash") {
212
+ const cmd = String(input?.command ?? "").slice(0, 80);
213
+ detail = cmd;
214
+ } else if (toolName === "Grep" || toolName === "Glob") {
215
+ detail = String(input?.pattern ?? input?.glob ?? "");
216
+ }
217
+
218
+ progressCallback({
219
+ type: "tool_use",
220
+ tool: toolName,
221
+ detail,
222
+ timestamp: new Date().toISOString()
223
+ });
224
+ } else if (block.type === "text" && typeof block.text === "string") {
225
+ progressCallback({
226
+ type: "text",
227
+ detail: block.text.slice(0, 200),
228
+ timestamp: new Date().toISOString()
229
+ });
230
+ }
231
+ }
232
+ }
233
+ }
234
+ } catch {
235
+ // Not JSON — write as-is (shouldn't happen with stream-json)
236
+ }
237
+ });
238
+
239
+ let stderrBuf = "";
240
+ child.stderr?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString(); });
241
+
242
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
243
+ if (this.timeoutSeconds) {
244
+ timeoutHandle = setTimeout(() => {
245
+ this.logger.error(`Spawn timed out after ${this.timeoutSeconds}s, killing PID ${child.pid}`);
246
+ child.kill("SIGTERM");
247
+ }, this.timeoutSeconds * 1000);
248
+ }
249
+
250
+ let exitCode: number | null | undefined;
251
+ let exited = false;
252
+
253
+ child.on("exit", (code) => {
254
+ exitCode = code;
255
+ exited = true;
256
+ if (timeoutHandle) clearTimeout(timeoutHandle);
257
+ outStream.end();
258
+ });
259
+
260
+ if (child.pid === undefined) {
261
+ outStream.end();
262
+ throw new AgentRunnerError("Failed to spawn Claude Code process.", "Spawn failed: no PID", outputPath);
263
+ }
264
+
265
+ const pid = child.pid;
266
+ const logger = this.logger;
267
+
268
+ const spawnedRun: SpawnedRun = {
269
+ pid,
270
+ outputPath,
271
+ set onProgress(cb: ProgressCallback | undefined) {
272
+ progressCallback = cb;
273
+ },
274
+ async poll(): Promise<SpawnedRunResult | undefined> {
275
+ if (!exited) return undefined;
276
+
277
+ // Use the parsed result text, fall back to reading file
278
+ let summary = lastResult.trim();
279
+ if (!summary) {
280
+ try {
281
+ const raw = await readFile(outputPath, "utf8");
282
+ // Try to extract result from the last line of stream-json
283
+ const lines = raw.trim().split("\n");
284
+ for (let i = lines.length - 1; i >= 0; i--) {
285
+ try {
286
+ const parsed = JSON.parse(lines[i]) as Record<string, unknown>;
287
+ if (parsed.type === "result" && typeof parsed.result === "string") {
288
+ summary = parsed.result;
289
+ break;
290
+ }
291
+ } catch { continue; }
292
+ }
293
+ if (!summary) summary = "Claude Code finished without output.";
294
+ } catch {
295
+ summary = stderrBuf.trim() || "No output captured.";
296
+ }
297
+ }
298
+
299
+ if (summary.length > 600) summary = summary.slice(0, 600);
300
+
301
+ // exitCode is set by the 'exit' event (number | null); undefined means not yet exited
302
+ const resolvedCode: number | null = exitCode ?? null;
303
+ if (resolvedCode === 0 || resolvedCode === null) {
304
+ logger.info(`Spawned process exited. PID=${pid} exitCode=${resolvedCode} output=${outputPath}`);
305
+ return { exitCode: resolvedCode, summary, outputPath };
306
+ } else {
307
+ const errorMsg = stderrBuf.slice(0, 300) || `Exit code ${resolvedCode}`;
308
+ logger.error(`Spawned process failed. PID=${pid} exitCode=${resolvedCode} stderr=${stderrBuf.slice(0, 200)}`);
309
+ return { exitCode: resolvedCode, summary, outputPath, error: errorMsg };
310
+ }
311
+ },
312
+ kill() {
313
+ if (!exited) {
314
+ child.kill("SIGTERM");
315
+ }
316
+ }
317
+ };
318
+
319
+ return spawnedRun;
320
+ }
321
+
135
322
  async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
136
323
  const signal = context?.signal;
137
324
  const outputPath = await createOutputPath("claude-contextual", issue.iid);
@@ -1,6 +1,6 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn as spawnChild } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- import { writeFile } from "node:fs/promises";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
@@ -13,8 +13,11 @@ import {
13
13
  type AgentRunResult,
14
14
  type AgentRunner,
15
15
  type AgentRunnerOptions,
16
+ type SpawnedRun,
17
+ type SpawnedRunResult,
16
18
  buildAgentPrompt,
17
19
  buildContextualPrompt,
20
+ createCompletedSpawnedRun,
18
21
  createOutputPath,
19
22
  isSmokeIssue,
20
23
  readSummary,
@@ -138,6 +141,108 @@ export class CodexRunner implements AgentRunner {
138
141
  };
139
142
  }
140
143
 
144
+ async spawn(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<SpawnedRun> {
145
+ const outputPath = await createOutputPath("codex", issue.iid);
146
+
147
+ if (isSmokeIssue(issue)) {
148
+ const result = await this.run(issue, worktree, context);
149
+ return createCompletedSpawnedRun(result);
150
+ }
151
+
152
+ const prompt = buildAgentPrompt({
153
+ issueIid: issue.iid,
154
+ issueTitle: issue.title,
155
+ gitlabHost: this.gitlabHost,
156
+ gitlabProjectId: this.gitlabProjectId,
157
+ worktreePath: worktree.worktreePath,
158
+ branch: worktree.branch,
159
+ provider: "codex",
160
+ todoId: context?.todoId,
161
+ preamble: this.agentDefinition?.prompt.preamble,
162
+ append: this.agentDefinition?.prompt.append,
163
+ labels: this.agentDefinition?.labels,
164
+ });
165
+ const codexCommand = resolveCodexCommand(this.env);
166
+
167
+ const args = ["exec", "--full-auto", "-C", worktree.worktreePath, "--output-last-message", outputPath];
168
+ if (this.agentDefinition?.model) {
169
+ args.push("--model", this.agentDefinition.model);
170
+ }
171
+ args.push(prompt);
172
+
173
+ this.logger.info(`Spawning: ${codexCommand} exec --full-auto -C ${worktree.worktreePath}`);
174
+ this.logger.info(`Prompt (${prompt.length} chars): issue #${issue.iid} "${issue.title}"`);
175
+
176
+ const child = spawnChild(codexCommand, args, {
177
+ env: this.env,
178
+ stdio: ["ignore", "pipe", "pipe"],
179
+ detached: false,
180
+ });
181
+
182
+ let stderrBuf = "";
183
+ child.stdout?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString(); /* discard stdout, codex writes to --output-last-message */ });
184
+ child.stderr?.on("data", (chunk: Buffer) => { stderrBuf += chunk.toString(); });
185
+
186
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
187
+ if (this.timeoutSeconds) {
188
+ timeoutHandle = setTimeout(() => {
189
+ this.logger.error(`Spawn timed out after ${this.timeoutSeconds}s, killing PID ${child.pid}`);
190
+ child.kill("SIGTERM");
191
+ }, this.timeoutSeconds * 1000);
192
+ }
193
+
194
+ let exitCode: number | null | undefined;
195
+ let exited = false;
196
+
197
+ child.on("exit", (code) => {
198
+ exitCode = code;
199
+ exited = true;
200
+ if (timeoutHandle) clearTimeout(timeoutHandle);
201
+ });
202
+
203
+ if (child.pid === undefined) {
204
+ throw new AgentRunnerError("Failed to spawn Codex process.", "Spawn failed: no PID", outputPath);
205
+ }
206
+
207
+ const pid = child.pid;
208
+ const timeoutSec = this.timeoutSeconds;
209
+ const logger = this.logger;
210
+
211
+ return {
212
+ pid,
213
+ outputPath,
214
+ async poll(): Promise<SpawnedRunResult | undefined> {
215
+ if (!exited) return undefined;
216
+
217
+ let summary: string;
218
+ try {
219
+ const raw = await readFile(outputPath, "utf8");
220
+ summary = raw.trim() || "Codex finished without output.";
221
+ } catch {
222
+ summary = stderrBuf.trim() || "No output captured.";
223
+ }
224
+
225
+ if (summary.length > 600) summary = summary.slice(0, 600);
226
+
227
+ // exitCode is set by the 'exit' event (number | null); undefined means not yet exited
228
+ const resolvedCode: number | null = exitCode ?? null;
229
+ if (resolvedCode === 0 || resolvedCode === null) {
230
+ logger.info(`Spawned process exited. PID=${pid} exitCode=${resolvedCode} output=${outputPath}`);
231
+ return { exitCode: resolvedCode, summary, outputPath };
232
+ } else {
233
+ const errorMsg = stderrBuf.slice(0, 300) || `Exit code ${resolvedCode}`;
234
+ logger.error(`Spawned process failed. PID=${pid} exitCode=${resolvedCode} stderr=${stderrBuf.slice(0, 200)}`);
235
+ return { exitCode: resolvedCode, summary, outputPath, error: errorMsg };
236
+ }
237
+ },
238
+ kill() {
239
+ if (!exited) {
240
+ child.kill("SIGTERM");
241
+ }
242
+ }
243
+ };
244
+ }
245
+
141
246
  async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
142
247
  const signal = context?.signal;
143
248
  const outputPath = await createOutputPath("codex-contextual", issue.iid);
@@ -0,0 +1,226 @@
1
+ // ──────────────────────────────────────────────────────────────────────────────
2
+ // Feishu (Lark) App Client — send & update interactive cards to specific users
3
+ // ──────────────────────────────────────────────────────────────────────────────
4
+
5
+ export interface FeishuClientOptions {
6
+ appId: string;
7
+ appSecret: string;
8
+ /** Override base URL for testing. Default: https://open.feishu.cn */
9
+ baseUrl?: string;
10
+ }
11
+
12
+ export interface FeishuCard {
13
+ header: {
14
+ title: { tag: "plain_text"; content: string };
15
+ template: "blue" | "green" | "red" | "orange" | "purple";
16
+ };
17
+ elements: unknown[];
18
+ }
19
+
20
+ export class FeishuClient {
21
+ private readonly appId: string;
22
+ private readonly appSecret: string;
23
+ private readonly baseUrl: string;
24
+
25
+ // Token cache
26
+ private accessToken?: string;
27
+ private tokenExpiresAt = 0;
28
+
29
+ // User ID cache: email → open_id
30
+ private userCache = new Map<string, string>();
31
+
32
+ constructor(options: FeishuClientOptions) {
33
+ this.appId = options.appId;
34
+ this.appSecret = options.appSecret;
35
+ this.baseUrl = options.baseUrl ?? "https://open.feishu.cn";
36
+ }
37
+
38
+ /** Get or refresh app access token */
39
+ private async getToken(): Promise<string> {
40
+ if (this.accessToken && Date.now() < this.tokenExpiresAt) {
41
+ return this.accessToken;
42
+ }
43
+
44
+ const response = await fetch(`${this.baseUrl}/open-apis/auth/v3/app_access_token/internal`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
48
+ signal: AbortSignal.timeout(10_000)
49
+ });
50
+
51
+ const data = await response.json() as { code: number; app_access_token?: string; expire?: number; msg?: string };
52
+ if (data.code !== 0 || !data.app_access_token) {
53
+ throw new Error(`Feishu token error: ${data.msg ?? `code ${data.code}`}`);
54
+ }
55
+
56
+ this.accessToken = data.app_access_token;
57
+ // Refresh 5 minutes before expiry
58
+ this.tokenExpiresAt = Date.now() + ((data.expire ?? 7200) - 300) * 1000;
59
+ return this.accessToken;
60
+ }
61
+
62
+ /** Look up Feishu open_id by email */
63
+ async getUserByEmail(email: string): Promise<string | undefined> {
64
+ const cached = this.userCache.get(email);
65
+ if (cached) return cached;
66
+
67
+ const token = await this.getToken();
68
+ const response = await fetch(`${this.baseUrl}/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Authorization": `Bearer ${token}`,
72
+ "Content-Type": "application/json"
73
+ },
74
+ body: JSON.stringify({ emails: [email] }),
75
+ signal: AbortSignal.timeout(10_000)
76
+ });
77
+
78
+ const data = await response.json() as {
79
+ code: number;
80
+ data?: { user_list?: Array<{ email?: string; user_id?: string }> };
81
+ };
82
+
83
+ if (data.code !== 0) return undefined;
84
+
85
+ const user = data.data?.user_list?.find(u => u.email === email);
86
+ if (user?.user_id) {
87
+ this.userCache.set(email, user.user_id);
88
+ return user.user_id;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** Send an interactive card to a user, returns message_id for later updates */
94
+ async sendCard(openId: string, card: FeishuCard): Promise<string | undefined> {
95
+ const token = await this.getToken();
96
+ const response = await fetch(`${this.baseUrl}/open-apis/im/v1/messages?receive_id_type=open_id`, {
97
+ method: "POST",
98
+ headers: {
99
+ "Authorization": `Bearer ${token}`,
100
+ "Content-Type": "application/json"
101
+ },
102
+ body: JSON.stringify({
103
+ receive_id: openId,
104
+ msg_type: "interactive",
105
+ content: JSON.stringify(card)
106
+ }),
107
+ signal: AbortSignal.timeout(10_000)
108
+ });
109
+
110
+ const data = await response.json() as {
111
+ code: number;
112
+ data?: { message_id?: string };
113
+ msg?: string;
114
+ };
115
+
116
+ if (data.code !== 0) {
117
+ throw new Error(`Feishu send failed: ${data.msg ?? `code ${data.code}`}`);
118
+ }
119
+
120
+ return data.data?.message_id;
121
+ }
122
+
123
+ /** Update an existing card message */
124
+ async updateCard(messageId: string, card: FeishuCard): Promise<void> {
125
+ const token = await this.getToken();
126
+ const response = await fetch(`${this.baseUrl}/open-apis/im/v1/messages/${messageId}`, {
127
+ method: "PATCH",
128
+ headers: {
129
+ "Authorization": `Bearer ${token}`,
130
+ "Content-Type": "application/json"
131
+ },
132
+ body: JSON.stringify({
133
+ content: JSON.stringify(card)
134
+ }),
135
+ signal: AbortSignal.timeout(10_000)
136
+ });
137
+
138
+ const data = await response.json() as { code: number; msg?: string };
139
+ if (data.code !== 0) {
140
+ throw new Error(`Feishu update failed: ${data.msg ?? `code ${data.code}`}`);
141
+ }
142
+ }
143
+ }
144
+
145
+ // ──────────────────────────────────────────────────────────────────────────────
146
+ // Card builder helpers
147
+ // ──────────────────────────────────────────────────────────────────────────────
148
+
149
+ export function buildProgressCard(options: {
150
+ agentName: string;
151
+ issueIid: number;
152
+ issueTitle: string;
153
+ issueUrl?: string;
154
+ mrUrl?: string;
155
+ status: "accepted" | "running" | "completed" | "failed" | "queued";
156
+ progressLines?: string[]; // e.g. ["📖 阅读 README.md", "✏️ 编辑 utils.js"]
157
+ duration?: string; // e.g. "2m 30s"
158
+ summary?: string; // final summary on completion/failure
159
+ }): FeishuCard {
160
+ const statusConfig: Record<string, { color: FeishuCard["header"]["template"]; emoji: string }> = {
161
+ accepted: { color: "blue", emoji: "🤖" },
162
+ running: { color: "blue", emoji: "🔧" },
163
+ completed: { color: "green", emoji: "✅" },
164
+ failed: { color: "red", emoji: "❌" },
165
+ queued: { color: "orange", emoji: "⏳" },
166
+ };
167
+
168
+ const cfg = statusConfig[options.status] ?? statusConfig.accepted;
169
+ const title = `${cfg.emoji} ${options.agentName} · #${options.issueIid} ${options.issueTitle}`;
170
+
171
+ const elements: unknown[] = [];
172
+
173
+ // Progress lines
174
+ if (options.progressLines && options.progressLines.length > 0) {
175
+ elements.push({
176
+ tag: "div",
177
+ text: { tag: "lark_md", content: options.progressLines.join("\n") }
178
+ });
179
+ }
180
+
181
+ // Summary (for completed/failed)
182
+ if (options.summary) {
183
+ elements.push({
184
+ tag: "div",
185
+ text: { tag: "lark_md", content: options.summary.slice(0, 500) }
186
+ });
187
+ }
188
+
189
+ // Duration
190
+ if (options.duration) {
191
+ elements.push({
192
+ tag: "div",
193
+ text: { tag: "lark_md", content: `⏱ ${options.duration}` }
194
+ });
195
+ }
196
+
197
+ // Action buttons
198
+ const actions: unknown[] = [];
199
+ if (options.issueUrl) {
200
+ actions.push({
201
+ tag: "button",
202
+ text: { tag: "plain_text", content: "查看 Issue" },
203
+ type: "default",
204
+ url: options.issueUrl
205
+ });
206
+ }
207
+ if (options.mrUrl) {
208
+ actions.push({
209
+ tag: "button",
210
+ text: { tag: "plain_text", content: "查看 MR" },
211
+ type: "primary",
212
+ url: options.mrUrl
213
+ });
214
+ }
215
+ if (actions.length > 0) {
216
+ elements.push({ tag: "action", actions });
217
+ }
218
+
219
+ return {
220
+ header: {
221
+ title: { tag: "plain_text", content: title.slice(0, 100) },
222
+ template: cfg.color
223
+ },
224
+ elements
225
+ };
226
+ }