@keyou007/agent-bot 0.1.15-alpha.4 → 0.1.15-alpha.6

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -2
  3. package/README.zh.md +7 -2
  4. package/config.example.yaml +6 -0
  5. package/dist/commands/CommandRouter.js +7 -0
  6. package/dist/commands/CommandRouter.js.map +1 -1
  7. package/dist/config/loadConfig.js +4 -0
  8. package/dist/config/loadConfig.js.map +1 -1
  9. package/dist/config/schema.js +31 -0
  10. package/dist/config/schema.js.map +1 -1
  11. package/dist/feishu/CardRenderer.js +76 -9
  12. package/dist/feishu/CardRenderer.js.map +1 -1
  13. package/dist/feishu/FinalResponseBranding.js +1 -1
  14. package/dist/feishu/FinalResponseBranding.js.map +1 -1
  15. package/dist/feishu/GroupNameFormatter.js +193 -0
  16. package/dist/feishu/GroupNameFormatter.js.map +1 -0
  17. package/dist/proxy/ProxySessionController.js +197 -148
  18. package/dist/proxy/ProxySessionController.js.map +1 -1
  19. package/dist/shell/ShellCommandJobManager.js +264 -0
  20. package/dist/shell/ShellCommandJobManager.js.map +1 -0
  21. package/dist/shell/shellCommandRunner.js +157 -0
  22. package/dist/shell/shellCommandRunner.js.map +1 -0
  23. package/dist/utils/executeShellCommand.js +1 -1
  24. package/dist/utils/executeShellCommand.js.map +1 -1
  25. package/docs/technical-reference.md +19 -0
  26. package/docs/technical-reference.zh.md +19 -0
  27. package/npm-shrinkwrap.json +2 -2
  28. package/package.json +1 -1
  29. package/src/commands/CommandRouter.ts +6 -0
  30. package/src/commands/commandTypes.ts +1 -0
  31. package/src/config/loadConfig.ts +4 -0
  32. package/src/config/schema.ts +33 -0
  33. package/src/feishu/CardRenderer.ts +91 -17
  34. package/src/feishu/FinalResponseBranding.ts +2 -1
  35. package/src/feishu/GroupNameFormatter.ts +222 -0
  36. package/src/proxy/ProxySessionController.ts +238 -160
  37. package/src/shell/ShellCommandJobManager.ts +341 -0
  38. package/src/shell/shellCommandRunner.ts +163 -0
  39. package/src/utils/executeShellCommand.ts +1 -1
@@ -3,6 +3,37 @@ import { DEFAULT_LOG_PATH, DEFAULT_SQLITE_PATH } from "./paths.js";
3
3
 
4
4
  const envRecordSchema = z.record(z.string(), z.string()).default({});
5
5
 
6
+ export const DEFAULT_GROUP_NAME_FORMAT = {
7
+ project: "[{agent}] [{project}] {taskname}",
8
+ projectless: "[{agent}] {taskname}",
9
+ dateFormat: "MM-dd",
10
+ } as const;
11
+
12
+ const groupNameTemplateSchema = z.string().min(1).max(240).superRefine((template, context) => {
13
+ const tokens = [...template.matchAll(/\{([^{}]+)\}/gu)].map((match) => match[1]!);
14
+ const taskNameCount = tokens.filter((token) => token === "taskname").length;
15
+ if (taskNameCount !== 1) {
16
+ context.addIssue({
17
+ code: "custom",
18
+ message: "A group name format must contain exactly one {taskname} placeholder.",
19
+ });
20
+ }
21
+ for (const token of tokens) {
22
+ if (["os", "agent", "project", "taskname", "date"].includes(token)) continue;
23
+ if (token.startsWith("date:") && token.slice("date:".length).trim()) continue;
24
+ context.addIssue({
25
+ code: "custom",
26
+ message: `Unsupported group name placeholder: {${token}}.`,
27
+ });
28
+ }
29
+ });
30
+
31
+ const groupNameFormatSchema = z.object({
32
+ project: groupNameTemplateSchema.default(DEFAULT_GROUP_NAME_FORMAT.project),
33
+ projectless: groupNameTemplateSchema.default(DEFAULT_GROUP_NAME_FORMAT.projectless),
34
+ dateFormat: z.string().min(1).max(80).default(DEFAULT_GROUP_NAME_FORMAT.dateFormat),
35
+ }).default(DEFAULT_GROUP_NAME_FORMAT);
36
+
6
37
  const agentKindSchema = z.union([
7
38
  z.literal("acp"),
8
39
  z.literal("app-server"),
@@ -30,6 +61,7 @@ export const appConfigSchema = z.object({
30
61
  userOpenId: z.string().optional(),
31
62
  respondToOwnerOnly: z.boolean().default(true),
32
63
  respondToAllGroupMessages: z.boolean().default(true),
64
+ groupNameFormat: groupNameFormatSchema,
33
65
  thinkingCardLayout: z.enum(["grouped", "timeline"]).default("grouped"),
34
66
  useConsoleWhenMissingCredentials: z.boolean().default(false),
35
67
  }),
@@ -53,4 +85,5 @@ export const appConfigSchema = z.object({
53
85
  });
54
86
 
55
87
  export type AgentConfig = z.infer<typeof agentConfigSchema>;
88
+ export type GroupNameFormatConfig = z.infer<typeof groupNameFormatSchema>;
56
89
  export type AppConfig = z.infer<typeof appConfigSchema>;
@@ -240,11 +240,12 @@ export interface DismissGroupCardView {
240
240
  }
241
241
 
242
242
  export interface ShellCommandCardView {
243
+ jobId?: string;
244
+ contextKey?: string;
243
245
  command: string;
244
246
  cwd: string;
245
- stdout: string;
246
- stderr: string;
247
- status: "running" | "completed" | "failed" | "timed_out";
247
+ output: string;
248
+ status: "running" | "cancelling" | "completed" | "failed" | "cancelled" | "timed_out";
248
249
  exitCode?: number | null;
249
250
  elapsedMs: number;
250
251
  outputTruncated: boolean;
@@ -258,12 +259,7 @@ export class CardRenderer {
258
259
  }
259
260
 
260
261
  renderShellCommandCard(view: ShellCommandCardView): Record<string, unknown> {
261
- const stdout = normalizeTerminalOutput(view.stdout).trimEnd();
262
- const stderr = normalizeTerminalOutput(view.stderr).trimEnd();
263
- const rawOutput = [
264
- stdout,
265
- stderr ? `[stderr]\n${stderr}` : "",
266
- ].filter(Boolean).join("\n");
262
+ const rawOutput = normalizeTerminalOutput(view.output).trimEnd();
267
263
  const displayOutput = truncateMiddle(
268
264
  rawOutput,
269
265
  6_000,
@@ -273,15 +269,32 @@ export class CardRenderer {
273
269
  const output = displayOutput || (view.status === "running" ? "等待输出..." : "(无输出)");
274
270
  const status = shellCommandStatus(view);
275
271
  const outputWasTruncated = view.outputTruncated || rawOutput.length > 6_000;
272
+ const elements = [
273
+ markdown(codeBlock(`$ ${command}\n${output}`, 6_700)),
274
+ markdown([
275
+ `${inlineCode(view.cwd)} · ${status.label} · ${formatTurnDuration(view.elapsedMs)}`,
276
+ ...(outputWasTruncated ? ["输出过长,已保留开头和结尾并截断中间内容。"] : []),
277
+ ].join("\n")),
278
+ ];
279
+ if (view.status === "running" && view.jobId && view.contextKey) {
280
+ elements.push({
281
+ tag: "button",
282
+ text: { tag: "plain_text", content: "Cancel" },
283
+ type: "danger",
284
+ size: "small",
285
+ behaviors: [{
286
+ type: "callback",
287
+ value: {
288
+ action: "shell_command_cancel",
289
+ jobId: view.jobId,
290
+ contextKey: view.contextKey,
291
+ },
292
+ }],
293
+ });
294
+ }
276
295
  return sectionCard(
277
296
  status.title,
278
- [
279
- markdown(codeBlock(`$ ${command}\n${output}`, 6_700)),
280
- markdown([
281
- `${inlineCode(view.cwd)} · ${status.label} · ${formatTurnDuration(view.elapsedMs)}`,
282
- ...(outputWasTruncated ? ["输出过长,已保留开头和结尾并截断中间内容。"] : []),
283
- ].join("\n")),
284
- ],
297
+ elements,
285
298
  status.template,
286
299
  );
287
300
  }
@@ -2052,11 +2065,66 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
2052
2065
  const displayCommand = tool.kind === "command"
2053
2066
  ? unwrapShellCommand(normalizedCommand) ?? normalizedCommand
2054
2067
  : normalizedCommand;
2055
- const commandText = truncateText(displayCommand, 600);
2068
+ const commandText = truncateText(
2069
+ tool.kind === "command" ? formatShellCommandForDisplay(displayCommand) : displayCommand,
2070
+ 600,
2071
+ );
2056
2072
  const resultText = result ? truncateToolResult(normalizeTerminalOutput(result).trim()) : undefined;
2057
2073
  return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
2058
2074
  }
2059
2075
 
2076
+ function formatShellCommandForDisplay(command: string): string {
2077
+ const normalized = command.replace(/\r\n/g, "\n");
2078
+ let result = "";
2079
+ let quote: "'" | "\"" | undefined;
2080
+
2081
+ for (let index = 0; index < normalized.length; index += 1) {
2082
+ const character = normalized[index] ?? "";
2083
+
2084
+ if (quote) {
2085
+ result += character;
2086
+ if ((character === "\\" || character === "`") && quote === "\"") {
2087
+ if (index + 1 < normalized.length) result += normalized[++index];
2088
+ } else if (character === quote) {
2089
+ quote = undefined;
2090
+ }
2091
+ continue;
2092
+ }
2093
+
2094
+ if (character === "'" || character === "\"") {
2095
+ quote = character;
2096
+ result += character;
2097
+ continue;
2098
+ }
2099
+
2100
+ if (character === "\\" || character === "`" || character === "^") {
2101
+ result += character;
2102
+ if (index + 1 < normalized.length) result += normalized[++index];
2103
+ continue;
2104
+ }
2105
+
2106
+ const operator = readShellDisplayOperator(normalized, index);
2107
+ if (!operator) {
2108
+ result += character;
2109
+ continue;
2110
+ }
2111
+
2112
+ result += operator;
2113
+ index += operator.length - 1;
2114
+ while (index + 1 < normalized.length && /\s/.test(normalized[index + 1] ?? "")) index += 1;
2115
+ if (index + 1 < normalized.length) result += " \\\n ";
2116
+ }
2117
+
2118
+ return result;
2119
+ }
2120
+
2121
+ function readShellDisplayOperator(command: string, index: number): string | undefined {
2122
+ for (const operator of ["&&", "||", "|&", ";", "|"]) {
2123
+ if (command.startsWith(operator, index)) return operator;
2124
+ }
2125
+ return undefined;
2126
+ }
2127
+
2060
2128
  function normalizeTerminalOutput(value: string): string {
2061
2129
  const clean = stripAnsi(value).replace(/\r\n/g, "\n");
2062
2130
  return clean
@@ -2167,6 +2235,12 @@ function shellCommandStatus(view: ShellCommandCardView): {
2167
2235
  if (view.status === "running") {
2168
2236
  return { title: "正在执行命令", label: "运行中", template: "blue" };
2169
2237
  }
2238
+ if (view.status === "cancelling") {
2239
+ return { title: "正在取消命令", label: "正在取消", template: "orange" };
2240
+ }
2241
+ if (view.status === "cancelled") {
2242
+ return { title: "命令已取消", label: "已取消", template: "grey" };
2243
+ }
2170
2244
  if (view.status === "timed_out") {
2171
2245
  return { title: "命令执行超时", label: "已超时(120s)", template: "orange" };
2172
2246
  }
@@ -1,4 +1,5 @@
1
- export const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
1
+ export const FINAL_RESPONSE_BRANDING =
2
+ "> Powered by [AgentBot](https://keyou.github.io/agent-bot/)";
2
3
  export const FINAL_RESPONSE_BRANDING_BLOCK = `----\n\n${FINAL_RESPONSE_BRANDING}`;
3
4
 
4
5
  export function splitFinalResponseBranding(markdown: string): {
@@ -0,0 +1,222 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import {
4
+ DEFAULT_GROUP_NAME_FORMAT,
5
+ type GroupNameFormatConfig,
6
+ } from "../config/schema.js";
7
+
8
+ export const FEISHU_GROUP_NAME_MAX_LENGTH = 60;
9
+ const GROUP_PROJECT_NAME_MAX_LENGTH = 15;
10
+ const PLACEHOLDER_PATTERN = /\{([^{}]+)\}/gu;
11
+ const TASK_NAME_PLACEHOLDER = "{taskname}";
12
+
13
+ export interface GroupNameFormatInput {
14
+ agentName: string;
15
+ projectCwd?: string;
16
+ taskName: string;
17
+ date?: Date;
18
+ platform?: NodeJS.Platform;
19
+ format?: GroupNameFormatConfig;
20
+ }
21
+
22
+ export interface GroupNameParseInput {
23
+ agentName: string;
24
+ groupName: string;
25
+ projectCwd?: string;
26
+ platform?: NodeJS.Platform;
27
+ format?: GroupNameFormatConfig;
28
+ }
29
+
30
+ export function formatNewGroupName(input: GroupNameFormatInput): string {
31
+ const format = input.format ?? DEFAULT_GROUP_NAME_FORMAT;
32
+ const template = input.projectCwd ? format.project : format.projectless;
33
+ const taskNameIndex = template.indexOf(TASK_NAME_PLACEHOLDER);
34
+ const values = templateValues(input, format);
35
+ const prefix = renderTemplate(template.slice(0, taskNameIndex), values);
36
+ const suffix = renderTemplate(template.slice(taskNameIndex + TASK_NAME_PLACEHOLDER.length), values);
37
+ const availableTaskNameLength = FEISHU_GROUP_NAME_MAX_LENGTH
38
+ - Array.from(prefix).length
39
+ - Array.from(suffix).length;
40
+ const taskName = truncateTail(input.taskName, availableTaskNameLength);
41
+ return truncateTail(`${prefix}${taskName}${suffix}`.trim(), FEISHU_GROUP_NAME_MAX_LENGTH);
42
+ }
43
+
44
+ export function parseTaskNameFromGroupName(input: GroupNameParseInput): string | undefined {
45
+ const format = input.format ?? DEFAULT_GROUP_NAME_FORMAT;
46
+ const template = input.projectCwd ? format.project : format.projectless;
47
+ const values = templateValues(input, format);
48
+ let pattern = "^";
49
+ let cursor = 0;
50
+ let match: RegExpExecArray | null;
51
+ PLACEHOLDER_PATTERN.lastIndex = 0;
52
+ while ((match = PLACEHOLDER_PATTERN.exec(template)) !== null) {
53
+ pattern += escapeRegExp(template.slice(cursor, match.index));
54
+ const token = match[1]!;
55
+ if (token === "taskname") pattern += "(?<taskname>.+?)";
56
+ else if (token === "date" || token.startsWith("date:")) {
57
+ const dateFormat = token === "date" ? format.dateFormat : token.slice("date:".length);
58
+ pattern += dateFormatPattern(dateFormat);
59
+ } else {
60
+ pattern += escapeRegExp(placeholderValue(token, values));
61
+ }
62
+ cursor = match.index + match[0].length;
63
+ }
64
+ pattern += `${escapeRegExp(template.slice(cursor))}$`;
65
+ const parsed = new RegExp(pattern, "iu").exec(input.groupName.trim())?.groups?.taskname?.trim();
66
+ return parsed || undefined;
67
+ }
68
+
69
+ export function formatGroupNameDate(date: Date, format: string): string {
70
+ const year = date.getFullYear();
71
+ const month = date.getMonth() + 1;
72
+ const day = date.getDate();
73
+ const hour = date.getHours();
74
+ const minute = date.getMinutes();
75
+ const second = date.getSeconds();
76
+ const values = new Map<string, string>([
77
+ ["yyyy", String(year).padStart(4, "0")],
78
+ ["YYYY", String(year).padStart(4, "0")],
79
+ ["yy", String(year % 100).padStart(2, "0")],
80
+ ["YY", String(year % 100).padStart(2, "0")],
81
+ ["MM", String(month).padStart(2, "0")],
82
+ ["M", String(month)],
83
+ ["dd", String(day).padStart(2, "0")],
84
+ ["DD", String(day).padStart(2, "0")],
85
+ ["d", String(day)],
86
+ ["D", String(day)],
87
+ ["HH", String(hour).padStart(2, "0")],
88
+ ["H", String(hour)],
89
+ ["mm", String(minute).padStart(2, "0")],
90
+ ["m", String(minute)],
91
+ ["ss", String(second).padStart(2, "0")],
92
+ ["s", String(second)],
93
+ ]);
94
+ return replaceDateTokens(format, (token) => values.get(token)!);
95
+ }
96
+
97
+ export function groupNameOs(platform: NodeJS.Platform = process.platform): string {
98
+ if (platform === "win32") return "win";
99
+ if (platform === "darwin") return "mac";
100
+ if (platform === "linux") return "linux";
101
+ return platform;
102
+ }
103
+
104
+ export function formatGroupProjectName(projectCwd: string): string {
105
+ const value = abbreviateHomeDirectory(projectCwd);
106
+ const levels = value
107
+ .replace(/[\\/]+$/, "")
108
+ .split(/[\\/]+/)
109
+ .filter(Boolean)
110
+ .slice(-2);
111
+ const separator = value.includes("\\") ? path.win32.sep : path.posix.sep;
112
+ return fitTrailingPathLevels(levels, GROUP_PROJECT_NAME_MAX_LENGTH, separator);
113
+ }
114
+
115
+ interface TemplateValues {
116
+ agent: string;
117
+ project: string;
118
+ os: string;
119
+ date: Date;
120
+ dateFormat: string;
121
+ }
122
+
123
+ function templateValues(
124
+ input: Pick<GroupNameFormatInput, "agentName" | "projectCwd" | "date" | "platform">,
125
+ format: GroupNameFormatConfig,
126
+ ): TemplateValues {
127
+ return {
128
+ agent: input.agentName,
129
+ project: input.projectCwd ? formatGroupProjectName(input.projectCwd) : "",
130
+ os: groupNameOs(input.platform),
131
+ date: input.date ?? new Date(),
132
+ dateFormat: format.dateFormat,
133
+ };
134
+ }
135
+
136
+ function renderTemplate(template: string, values: TemplateValues): string {
137
+ PLACEHOLDER_PATTERN.lastIndex = 0;
138
+ return template.replace(PLACEHOLDER_PATTERN, (_placeholder, token: string) => {
139
+ if (token === "date") return formatGroupNameDate(values.date, values.dateFormat);
140
+ if (token.startsWith("date:")) {
141
+ return formatGroupNameDate(values.date, token.slice("date:".length));
142
+ }
143
+ return placeholderValue(token, values);
144
+ });
145
+ }
146
+
147
+ function placeholderValue(token: string, values: TemplateValues): string {
148
+ if (token === "agent") return values.agent;
149
+ if (token === "project") return values.project;
150
+ if (token === "os") return values.os;
151
+ return "";
152
+ }
153
+
154
+ function dateFormatPattern(format: string): string {
155
+ return replaceDateTokens(format, (token) => {
156
+ if (["yyyy", "YYYY"].includes(token)) return "\\d{4}";
157
+ if (["yy", "YY", "MM", "dd", "DD", "HH", "mm", "ss"].includes(token)) return "\\d{2}";
158
+ return "\\d{1,2}";
159
+ }, escapeRegExp);
160
+ }
161
+
162
+ const DATE_TOKENS = [
163
+ "yyyy", "YYYY", "yy", "YY", "MM", "dd", "DD", "HH", "mm", "ss", "M", "d", "D", "H", "m", "s",
164
+ ];
165
+
166
+ function replaceDateTokens(
167
+ format: string,
168
+ replaceToken: (token: string) => string,
169
+ replaceLiteral: (literal: string) => string = (literal) => literal,
170
+ ): string {
171
+ let result = "";
172
+ for (let index = 0; index < format.length;) {
173
+ const token = DATE_TOKENS.find((candidate) => format.startsWith(candidate, index));
174
+ if (token) {
175
+ result += replaceToken(token);
176
+ index += token.length;
177
+ } else {
178
+ result += replaceLiteral(format[index]!);
179
+ index += 1;
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+
185
+ function abbreviateHomeDirectory(value: string): string {
186
+ const homeDirectory = os.homedir();
187
+ const valueIsWindows = isWindowsAbsolutePath(value);
188
+ const homeIsWindows = isWindowsAbsolutePath(homeDirectory);
189
+ if (valueIsWindows !== homeIsWindows && (valueIsWindows || path.posix.isAbsolute(value))) return value;
190
+ const pathApi = valueIsWindows ? path.win32 : path;
191
+ const relative = pathApi.relative(homeDirectory, value);
192
+ if (relative === "") return "~";
193
+ if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) return value;
194
+ return `~${pathApi.sep}${relative}`;
195
+ }
196
+
197
+ function isWindowsAbsolutePath(value: string): boolean {
198
+ return /^[a-z]:[\\/]|^\\\\/iu.test(value);
199
+ }
200
+
201
+ function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {
202
+ if (levels.length === 0) return "";
203
+ if (levels.length === 1) return truncateTail(levels[0]!, maxLength);
204
+ const parent = levels[0]!;
205
+ const leaf = levels[1]!;
206
+ const joined = `${parent}${separator}${leaf}`;
207
+ if (Array.from(joined).length <= maxLength) return joined;
208
+ if (Array.from(leaf).length <= maxLength) return leaf;
209
+ return truncateTail(leaf, maxLength);
210
+ }
211
+
212
+ function truncateTail(value: string, maxLength: number): string {
213
+ const characters = Array.from(value);
214
+ if (characters.length <= maxLength) return value;
215
+ if (maxLength <= 0) return "";
216
+ if (maxLength <= 3) return ".".repeat(maxLength);
217
+ return `${characters.slice(0, maxLength - 3).join("")}...`;
218
+ }
219
+
220
+ function escapeRegExp(value: string): string {
221
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
222
+ }