glab-agent 0.1.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.
@@ -0,0 +1,56 @@
1
+ import { createServer, type Server } from "node:http";
2
+
3
+ export interface HealthStatus {
4
+ agent: string;
5
+ status: "idle" | "busy" | "offline";
6
+ pid: number;
7
+ uptime: number; // seconds since start
8
+ cycleCount: number;
9
+ activeIssue?: number; // issueIid if busy
10
+ lastError?: string;
11
+ }
12
+
13
+ export interface HealthServerOptions {
14
+ port: number;
15
+ agent: string;
16
+ getStatus: () => HealthStatus;
17
+ }
18
+
19
+ export function startHealthServer(options: HealthServerOptions): { server: Server; close: () => void } {
20
+ const { port, getStatus } = options;
21
+
22
+ const server = createServer((req, res) => {
23
+ if (req.method === "GET" && req.url === "/health") {
24
+ const status = getStatus();
25
+ const body = JSON.stringify(status);
26
+ res.writeHead(200, {
27
+ "Content-Type": "application/json",
28
+ "Content-Length": Buffer.byteLength(body),
29
+ });
30
+ res.end(body);
31
+ } else {
32
+ const body = JSON.stringify({ error: "not found" });
33
+ res.writeHead(404, {
34
+ "Content-Type": "application/json",
35
+ "Content-Length": Buffer.byteLength(body),
36
+ });
37
+ res.end(body);
38
+ }
39
+ });
40
+
41
+ server.on("error", (err: NodeJS.ErrnoException) => {
42
+ if (err.code === "EADDRINUSE") {
43
+ console.warn(`[health-server] Port ${port} already in use, health endpoint not available`);
44
+ } else {
45
+ console.warn(`[health-server] Error: ${err.message}`);
46
+ }
47
+ });
48
+
49
+ server.listen(port, "0.0.0.0");
50
+
51
+ function close(): void {
52
+ server.close();
53
+ }
54
+
55
+ return { server, close };
56
+ }
@@ -0,0 +1,33 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface HeartbeatData {
5
+ timestamp: string;
6
+ cycleCount: number;
7
+ lastError?: string;
8
+ }
9
+
10
+ export async function writeHeartbeat(heartbeatPath: string, data: HeartbeatData): Promise<void> {
11
+ try {
12
+ await mkdir(path.dirname(heartbeatPath), { recursive: true });
13
+ await writeFile(heartbeatPath, JSON.stringify(data), "utf8");
14
+ } catch {
15
+ // Heartbeat write failure must never affect main workflow
16
+ }
17
+ }
18
+
19
+ export async function readHeartbeat(heartbeatPath: string): Promise<HeartbeatData | undefined> {
20
+ try {
21
+ const content = await readFile(heartbeatPath, "utf8");
22
+ return JSON.parse(content) as HeartbeatData;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ export function isHeartbeatStale(heartbeat: HeartbeatData, pollIntervalSeconds: number, maxMissedCycles = 3): boolean {
29
+ const heartbeatTime = new Date(heartbeat.timestamp).getTime();
30
+ const now = Date.now();
31
+ const thresholdMs = pollIntervalSeconds * maxMissedCycles * 1000;
32
+ return (now - heartbeatTime) > thresholdMs;
33
+ }
@@ -0,0 +1,56 @@
1
+ import { stat, rename, unlink } from "node:fs/promises";
2
+
3
+ export interface LogRotateOptions {
4
+ maxBytes: number; // default 10 * 1024 * 1024 (10MB)
5
+ maxFiles: number; // default 5
6
+ }
7
+
8
+ const DEFAULT_OPTIONS: LogRotateOptions = {
9
+ maxBytes: 10 * 1024 * 1024,
10
+ maxFiles: 5,
11
+ };
12
+
13
+ /**
14
+ * Check if a log file needs rotation and perform it if needed.
15
+ * Rotation scheme: file → file.1 → file.2 → ... → file.N (oldest deleted)
16
+ *
17
+ * @returns true if rotation was performed
18
+ */
19
+ export async function rotateIfNeeded(
20
+ filePath: string,
21
+ options?: Partial<LogRotateOptions>
22
+ ): Promise<boolean> {
23
+ const opts = { ...DEFAULT_OPTIONS, ...options };
24
+
25
+ try {
26
+ const stats = await stat(filePath);
27
+ if (stats.size < opts.maxBytes) return false;
28
+ } catch {
29
+ return false; // File doesn't exist
30
+ }
31
+
32
+ // Delete oldest rotated file
33
+ try {
34
+ await unlink(`${filePath}.${opts.maxFiles}`);
35
+ } catch {
36
+ /* doesn't exist */
37
+ }
38
+
39
+ // Shift existing rotated files: .4 → .5, .3 → .4, etc.
40
+ for (let i = opts.maxFiles - 1; i >= 1; i--) {
41
+ try {
42
+ await rename(`${filePath}.${i}`, `${filePath}.${i + 1}`);
43
+ } catch {
44
+ /* doesn't exist */
45
+ }
46
+ }
47
+
48
+ // Rotate current file to .1
49
+ try {
50
+ await rename(filePath, `${filePath}.1`);
51
+ } catch {
52
+ /* race condition, ignore */
53
+ }
54
+
55
+ return true;
56
+ }
@@ -0,0 +1,92 @@
1
+ export interface Logger {
2
+ info(...args: unknown[]): void;
3
+ warn(...args: unknown[]): void;
4
+ error(...args: unknown[]): void;
5
+ }
6
+
7
+ export type LogFormat = "text" | "json";
8
+
9
+ export interface LogEntry {
10
+ timestamp: string;
11
+ level: "info" | "warn" | "error";
12
+ agent?: string;
13
+ module: string;
14
+ message: string;
15
+ context?: Record<string, unknown>;
16
+ }
17
+
18
+ function timestamp(): string {
19
+ return new Date().toLocaleString("zh-CN", {
20
+ timeZone: "Asia/Shanghai",
21
+ year: "numeric",
22
+ month: "2-digit",
23
+ day: "2-digit",
24
+ hour: "2-digit",
25
+ minute: "2-digit",
26
+ second: "2-digit"
27
+ });
28
+ }
29
+
30
+ export function formatLogEntry(entry: LogEntry, format: LogFormat): string {
31
+ if (format === "json") {
32
+ return JSON.stringify(entry);
33
+ }
34
+ // text format: [timestamp] [agent] [module] message
35
+ const prefix = entry.agent ? `[${entry.agent}] [${entry.module}]` : `[${entry.module}]`;
36
+ const contextStr = entry.context ? ` ${JSON.stringify(entry.context)}` : "";
37
+ return `[${entry.timestamp}] ${prefix} ${entry.message}${contextStr}`;
38
+ }
39
+
40
+ /**
41
+ * Creates a tagged logger.
42
+ *
43
+ * Output format (text):
44
+ * [2026/04/06 14:33:25] [claude-coder] [watcher] message
45
+ * [2026/04/06 14:33:25] [claude-coder] [runner] message
46
+ *
47
+ * Output format (json):
48
+ * {"timestamp":"...","level":"info","agent":"claude-coder","module":"watcher","message":"..."}
49
+ *
50
+ * @param module Short name for the calling module (e.g. "watcher", "runner", "worktree")
51
+ * @param agent Optional agent name included after the timestamp for multi-agent disambiguation
52
+ * @param format Output format: "text" (default, human-readable) or "json" (JSON Lines / NDJSON)
53
+ */
54
+ export function createLogger(module: string, agent?: string, format: LogFormat = "text"): Logger {
55
+ const makeEntry = (level: "info" | "warn" | "error", args: unknown[]): LogEntry => {
56
+ // If last arg is a plain object, treat as context
57
+ let context: Record<string, unknown> | undefined;
58
+ let messageArgs = args;
59
+ if (
60
+ args.length > 0 &&
61
+ typeof args[args.length - 1] === "object" &&
62
+ args[args.length - 1] !== null &&
63
+ !Array.isArray(args[args.length - 1])
64
+ ) {
65
+ context = args[args.length - 1] as Record<string, unknown>;
66
+ messageArgs = args.slice(0, -1);
67
+ }
68
+ return {
69
+ timestamp: timestamp(),
70
+ level,
71
+ agent,
72
+ module,
73
+ message: messageArgs.map(String).join(" "),
74
+ context
75
+ };
76
+ };
77
+
78
+ return {
79
+ info: (...args: unknown[]) => {
80
+ const entry = makeEntry("info", args);
81
+ console.info(formatLogEntry(entry, format));
82
+ },
83
+ warn: (...args: unknown[]) => {
84
+ const entry = makeEntry("warn", args);
85
+ console.warn(formatLogEntry(entry, format));
86
+ },
87
+ error: (...args: unknown[]) => {
88
+ const entry = makeEntry("error", args);
89
+ console.error(formatLogEntry(entry, format));
90
+ }
91
+ };
92
+ }
@@ -0,0 +1,51 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { createInterface } from "node:readline";
4
+ import path from "node:path";
5
+
6
+ export interface MetricEvent {
7
+ timestamp: string;
8
+ agent: string;
9
+ provider: string;
10
+ event: "run_start" | "run_complete" | "run_failed" | "run_timeout" | "cycle_error";
11
+ issueIid?: number;
12
+ issueTitle?: string;
13
+ durationMs?: number;
14
+ branch?: string;
15
+ mrUrl?: string;
16
+ error?: string;
17
+ }
18
+
19
+ export async function appendMetric(metricsDir: string, agentName: string, event: MetricEvent): Promise<void> {
20
+ try {
21
+ await mkdir(metricsDir, { recursive: true });
22
+ const filePath = path.join(metricsDir, `${agentName}.jsonl`);
23
+ await appendFile(filePath, JSON.stringify(event) + "\n", "utf8");
24
+ } catch {
25
+ // Metrics write failure must never affect main workflow
26
+ }
27
+ }
28
+
29
+ export async function readMetrics(metricsDir: string, agentName: string): Promise<MetricEvent[]> {
30
+ const filePath = path.join(metricsDir, `${agentName}.jsonl`);
31
+ const events: MetricEvent[] = [];
32
+ try {
33
+ const rl = createInterface({ input: createReadStream(filePath, "utf8"), crlfDelay: Infinity });
34
+ for await (const line of rl) {
35
+ if (line.trim()) {
36
+ try {
37
+ events.push(JSON.parse(line) as MetricEvent);
38
+ } catch {
39
+ // Skip malformed lines
40
+ }
41
+ }
42
+ }
43
+ } catch {
44
+ // File not found or read error
45
+ }
46
+ return events;
47
+ }
48
+
49
+ export function metricsPath(repoPath: string): string {
50
+ return path.join(repoPath, ".glab-agent", "metrics");
51
+ }
@@ -0,0 +1,121 @@
1
+ import type { GitlabClient, GitlabTodoItem } from "./gitlab-glab-client.js";
2
+ import type { Logger } from "./logger.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type MrIntent = "rebase" | "contextual";
9
+
10
+ export type MrActionResult =
11
+ | { action: "rebase"; success: boolean; message: string }
12
+ | { action: "contextual"; message: string };
13
+
14
+ export interface ReplyContext {
15
+ targetType: "MergeRequest" | "Issue";
16
+ targetIid: number;
17
+ targetTitle: string;
18
+ targetDescription?: string;
19
+ mentionBody: string;
20
+ recentNotes: string[];
21
+ gitlabHost: string;
22
+ projectId: number;
23
+ }
24
+
25
+ export interface MrActionDependencies {
26
+ gitlabClient: Pick<GitlabClient, "addMergeRequestNote" | "markTodoDone" | "getMergeRequestNotes">;
27
+ rebaseBranch: (branch: string) => Promise<void>;
28
+ generateReply: (context: ReplyContext) => Promise<string>;
29
+ projectId: number;
30
+ gitlabHost: string;
31
+ logger?: Logger;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Intent parsing
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export function parseMrIntent(body: string | undefined): MrIntent {
39
+ if (!body) return "contextual";
40
+ if (/\brebase\b/i.test(body)) return "rebase";
41
+ return "contextual";
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // MR todo filtering
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export function filterMrTodos(
49
+ todos: GitlabTodoItem[],
50
+ processedTodoIds: Set<number>,
51
+ acceptedActions: Set<string>
52
+ ): GitlabTodoItem[] {
53
+ return todos.filter(
54
+ (todo) =>
55
+ todo.targetType === "MergeRequest" &&
56
+ todo.state === "pending" &&
57
+ acceptedActions.has(todo.actionName) &&
58
+ !processedTodoIds.has(todo.id)
59
+ );
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Handle a single MR todo
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export async function handleMrTodo(
67
+ todo: GitlabTodoItem,
68
+ deps: MrActionDependencies
69
+ ): Promise<MrActionResult> {
70
+ const logger = deps.logger ?? console;
71
+ const mrIid = todo.targetIid ?? 0;
72
+ const intent = parseMrIntent(todo.body);
73
+
74
+ if (intent === "rebase" && todo.sourceBranch) {
75
+ try {
76
+ await deps.rebaseBranch(todo.sourceBranch);
77
+ const note = `已完成 rebase(分支 \`${todo.sourceBranch}\`)。`;
78
+ await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, note);
79
+ logger.info(`MR !${mrIid}: rebase succeeded for branch ${todo.sourceBranch}`);
80
+ return { action: "rebase", success: true, message: note };
81
+ } catch (error) {
82
+ const reason = String(error).slice(0, 300);
83
+ const note = `rebase 失败:${reason}`;
84
+ try {
85
+ await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, note);
86
+ } catch { /* silent */ }
87
+ logger.warn(`MR !${mrIid}: rebase failed: ${reason}`);
88
+ return { action: "rebase", success: false, message: note };
89
+ }
90
+ }
91
+
92
+ // Contextual reply — collect context and delegate to AI
93
+ try {
94
+ const notes = await deps.gitlabClient.getMergeRequestNotes(deps.projectId, mrIid);
95
+ const recentNotes = notes
96
+ .filter((n) => !n.system)
97
+ .slice(-10)
98
+ .map((n) => n.body);
99
+
100
+ const reply = await deps.generateReply({
101
+ targetType: "MergeRequest",
102
+ targetIid: mrIid,
103
+ targetTitle: `MR !${mrIid}`,
104
+ mentionBody: todo.body ?? "",
105
+ recentNotes,
106
+ gitlabHost: deps.gitlabHost,
107
+ projectId: deps.projectId
108
+ });
109
+
110
+ await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, reply);
111
+ logger.info(`MR !${mrIid}: posted contextual reply`);
112
+ return { action: "contextual", message: reply };
113
+ } catch (error) {
114
+ const fallback = "收到!但处理你的请求时遇到了问题,请稍后重试或在 Issue 中 @我。";
115
+ try {
116
+ await deps.gitlabClient.addMergeRequestNote(deps.projectId, mrIid, fallback);
117
+ } catch { /* silent */ }
118
+ logger.warn(`MR !${mrIid}: contextual reply failed: ${String(error).slice(0, 300)}`);
119
+ return { action: "contextual", message: fallback };
120
+ }
121
+ }
@@ -0,0 +1,190 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export interface NotifyOptions {
7
+ title: string;
8
+ subtitle?: string;
9
+ message: string;
10
+ url?: string; // URL to open when notification is clicked
11
+ sound?: boolean; // default true
12
+ }
13
+
14
+ // Cache the terminal-notifier availability check
15
+ let terminalNotifierAvailable: boolean | undefined;
16
+
17
+ async function hasTerminalNotifier(): Promise<boolean> {
18
+ if (terminalNotifierAvailable !== undefined) return terminalNotifierAvailable;
19
+ try {
20
+ await execFileAsync("which", ["terminal-notifier"]);
21
+ terminalNotifierAvailable = true;
22
+ } catch {
23
+ terminalNotifierAvailable = false;
24
+ }
25
+ return terminalNotifierAvailable;
26
+ }
27
+
28
+ /** Reset cached availability — for testing only */
29
+ export function _resetTerminalNotifierCache(): void {
30
+ terminalNotifierAvailable = undefined;
31
+ }
32
+
33
+ export async function sendNotification(options: NotifyOptions): Promise<void> {
34
+ const { title, subtitle, message, url, sound = true } = options;
35
+
36
+ try {
37
+ if (process.platform !== "darwin") return; // macOS only
38
+
39
+ if (await hasTerminalNotifier()) {
40
+ const args = ["-title", title, "-message", message];
41
+ if (subtitle) args.push("-subtitle", subtitle);
42
+ if (url) args.push("-open", url);
43
+ if (sound) args.push("-sound", "default");
44
+ args.push("-group", "glab-agent"); // prevent notification stacking
45
+ await execFileAsync("terminal-notifier", args);
46
+ } else {
47
+ // Fallback to osascript
48
+ const escape = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
49
+ let script = `display notification "${escape(message)}" with title "${escape(title)}"`;
50
+ if (subtitle) script += ` subtitle "${escape(subtitle)}"`;
51
+ if (sound) script += ` sound name "default"`;
52
+ await execFileAsync("osascript", ["-e", script]);
53
+ }
54
+ } catch {
55
+ // Notification failures must never affect the main workflow
56
+ }
57
+ }
58
+
59
+ // ──────────────────────────────────────────────────────────────────────────────
60
+ // Webhook notification
61
+ // ──────────────────────────────────────────────────────────────────────────────
62
+
63
+ export interface WebhookPayload {
64
+ title: string;
65
+ message: string;
66
+ url?: string;
67
+ }
68
+
69
+ function isFeishuUrl(url: string): boolean {
70
+ return url.includes("feishu.cn") || url.includes("larksuite.com");
71
+ }
72
+
73
+ function isSlackUrl(url: string): boolean {
74
+ return url.includes("hooks.slack.com");
75
+ }
76
+
77
+ function buildWebhookBody(url: string, payload: WebhookPayload): unknown {
78
+ const text = payload.url
79
+ ? `${payload.title}\n${payload.message}\n${payload.url}`
80
+ : `${payload.title}\n${payload.message}`;
81
+
82
+ if (isFeishuUrl(url)) {
83
+ return {
84
+ msg_type: "text",
85
+ content: { text }
86
+ };
87
+ }
88
+
89
+ if (isSlackUrl(url)) {
90
+ return { text };
91
+ }
92
+
93
+ // Generic webhook — send a structured JSON body
94
+ return {
95
+ title: payload.title,
96
+ message: payload.message,
97
+ url: payload.url
98
+ };
99
+ }
100
+
101
+ export async function sendWebhook(
102
+ webhookUrl: string,
103
+ payload: WebhookPayload
104
+ ): Promise<void> {
105
+ try {
106
+ const body = buildWebhookBody(webhookUrl, payload);
107
+ const response = await fetch(webhookUrl, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify(body),
111
+ signal: AbortSignal.timeout(10_000)
112
+ });
113
+ if (!response.ok) {
114
+ // Read body to prevent connection leak, but ignore content
115
+ await response.text().catch(() => {});
116
+ }
117
+ } catch {
118
+ // Webhook failures must never affect the main workflow
119
+ }
120
+ }
121
+
122
+ // ──────────────────────────────────────────────────────────────────────────────
123
+ // Pre-defined notification templates
124
+ // ──────────────────────────────────────────────────────────────────────────────
125
+
126
+ export function notifyAccepted(
127
+ agentName: string,
128
+ issueIid: number,
129
+ issueTitle: string,
130
+ webhookUrl?: string
131
+ ): Promise<void> {
132
+ const title = `\u{1F916} ${agentName} 已接单`;
133
+ const message = `#${issueIid} ${issueTitle}`;
134
+ const promises: Promise<void>[] = [
135
+ sendNotification({
136
+ title: `\u{1F916} ${agentName}`,
137
+ subtitle: "\u5DF2\u63A5\u5355",
138
+ message,
139
+ })
140
+ ];
141
+ if (webhookUrl) {
142
+ promises.push(sendWebhook(webhookUrl, { title, message }));
143
+ }
144
+ return Promise.all(promises).then(() => {});
145
+ }
146
+
147
+ export function notifyCompleted(
148
+ agentName: string,
149
+ issueIid: number,
150
+ issueTitle: string,
151
+ mrUrl?: string,
152
+ webhookUrl?: string
153
+ ): Promise<void> {
154
+ const title = `\u2705 ${agentName} 已完成`;
155
+ const message = `#${issueIid} ${issueTitle}`;
156
+ const promises: Promise<void>[] = [
157
+ sendNotification({
158
+ title: `\u2705 ${agentName}`,
159
+ subtitle: "\u5DF2\u5B8C\u6210",
160
+ message,
161
+ url: mrUrl,
162
+ })
163
+ ];
164
+ if (webhookUrl) {
165
+ promises.push(sendWebhook(webhookUrl, { title, message, url: mrUrl }));
166
+ }
167
+ return Promise.all(promises).then(() => {});
168
+ }
169
+
170
+ export function notifyFailed(
171
+ agentName: string,
172
+ issueIid: number,
173
+ issueTitle: string,
174
+ summary: string,
175
+ webhookUrl?: string
176
+ ): Promise<void> {
177
+ const title = `\u274C ${agentName} 执行失败`;
178
+ const message = `#${issueIid} ${issueTitle}\n${summary.slice(0, 100)}`;
179
+ const promises: Promise<void>[] = [
180
+ sendNotification({
181
+ title: `\u274C ${agentName}`,
182
+ subtitle: "\u6267\u884C\u5931\u8D25",
183
+ message,
184
+ })
185
+ ];
186
+ if (webhookUrl) {
187
+ promises.push(sendWebhook(webhookUrl, { title, message }));
188
+ }
189
+ return Promise.all(promises).then(() => {});
190
+ }