@keyou007/agent-bot 0.1.2 → 0.1.4
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/.env.example +3 -1
- package/CHANGELOG.md +15 -0
- package/README.md +23 -8
- package/README.zh.md +23 -8
- package/config.example.yaml +1 -0
- package/dist/cli/FeishuAppConfiguration.js +10 -10
- package/dist/cli/FeishuAppConfiguration.js.map +1 -1
- package/dist/cli/FeishuAppRegistration.js +21 -11
- package/dist/cli/FeishuAppRegistration.js.map +1 -1
- package/dist/cli/Initializer.js +14 -8
- package/dist/cli/Initializer.js.map +1 -1
- package/dist/cli/LocalControlClient.js +2 -2
- package/dist/cli/LocalControlClient.js.map +1 -1
- package/dist/cli/LocalControlServer.js +1 -1
- package/dist/cli/LocalControlServer.js.map +1 -1
- package/dist/cli/ServerStarter.js +69 -0
- package/dist/cli/ServerStarter.js.map +1 -0
- package/dist/cli/SkillRegistry.js +4 -4
- package/dist/cli/SkillRegistry.js.map +1 -1
- package/dist/cli/help.js +5 -56
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/packageVersion.js +1 -1
- package/dist/cli/packageVersion.js.map +1 -1
- package/dist/cli/profile.js +40 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/serverStatus.js +37 -0
- package/dist/cli/serverStatus.js.map +1 -0
- package/dist/cli/taskChatRoute.js +2 -2
- package/dist/cli/taskChatRoute.js.map +1 -1
- package/dist/cli.js +148 -168
- package/dist/cli.js.map +1 -1
- package/dist/config/loadConfig.js +7 -2
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/paths.js +1 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/schema.js +1 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/CardRenderer.js +21 -3
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +7 -76
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/transport.js +1 -1
- package/dist/feishu/transport.js.map +1 -1
- package/dist/index.js +18 -9
- package/dist/index.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +28 -13
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +13 -2
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/dist/startup/startFeishu.js +1 -1
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +23 -7
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +20 -2
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/supervision/SupervisorDiagnostics.js +209 -0
- package/dist/supervision/SupervisorDiagnostics.js.map +1 -0
- package/dist/supervisor.js +47 -12
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +25 -5
- package/docs/technical-reference.zh.md +25 -5
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +24 -4
- package/src/cli/FeishuAppConfiguration.ts +10 -10
- package/src/cli/FeishuAppRegistration.ts +23 -10
- package/src/cli/Initializer.ts +14 -7
- package/src/cli/LocalControlClient.ts +2 -2
- package/src/cli/LocalControlServer.ts +1 -1
- package/src/cli/ServerStarter.ts +106 -0
- package/src/cli/SkillRegistry.ts +4 -4
- package/src/cli/help.ts +5 -65
- package/src/cli/packageVersion.ts +1 -1
- package/src/cli/profile.ts +59 -0
- package/src/cli/serverStatus.ts +39 -0
- package/src/cli/taskChatRoute.ts +2 -2
- package/src/cli.ts +160 -167
- package/src/config/loadConfig.ts +12 -2
- package/src/config/paths.ts +1 -0
- package/src/config/schema.ts +1 -0
- package/src/feishu/CardRenderer.ts +25 -3
- package/src/feishu/FeishuConnector.ts +6 -87
- package/src/feishu/transport.ts +1 -1
- package/src/index.ts +22 -9
- package/src/proxy/ProxySessionController.ts +28 -13
- package/src/startup/StartupNotifier.ts +21 -4
- package/src/startup/startFeishu.ts +1 -1
- package/src/supervision/SafeRestartNotifier.ts +22 -7
- package/src/supervision/SafeRestartScheduler.ts +27 -3
- package/src/supervision/SupervisorDiagnostics.ts +268 -0
- package/src/supervisor.ts +46 -11
package/src/config/loadConfig.ts
CHANGED
|
@@ -3,12 +3,18 @@ import path from "node:path";
|
|
|
3
3
|
import { config as loadDotEnv } from "dotenv";
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { appConfigSchema, type AppConfig } from "./schema.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AGENT_BOT_EXPLICIT_PROFILE_ENV,
|
|
8
|
+
defaultConfigPath,
|
|
9
|
+
defaultDotEnvPath,
|
|
10
|
+
resolveUserPath,
|
|
11
|
+
} from "./paths.js";
|
|
7
12
|
|
|
8
13
|
const ENV_PATTERN = /\$\{([A-Z0-9_]+)\}/gi;
|
|
9
14
|
const DEFAULT_CONFIG = `feishu:
|
|
10
15
|
appId: "\${FEISHU_APP_ID}"
|
|
11
16
|
appSecret: "\${FEISHU_APP_SECRET}"
|
|
17
|
+
userOpenId: "\${FEISHU_USER_OPEN_ID}"
|
|
12
18
|
|
|
13
19
|
console:
|
|
14
20
|
enabled: true
|
|
@@ -65,7 +71,11 @@ logging:
|
|
|
65
71
|
export function loadConfig(
|
|
66
72
|
configPath?: string,
|
|
67
73
|
): AppConfig {
|
|
68
|
-
loadDotEnv({
|
|
74
|
+
loadDotEnv({
|
|
75
|
+
path: defaultDotEnvPath(),
|
|
76
|
+
quiet: true,
|
|
77
|
+
override: process.env[AGENT_BOT_EXPLICIT_PROFILE_ENV] === "1",
|
|
78
|
+
});
|
|
69
79
|
|
|
70
80
|
const configuredPath = firstNonBlank(configPath, process.env.AGENT_BOT_CONFIG);
|
|
71
81
|
const absolutePath = resolveUserPath(configuredPath ?? defaultConfigPath());
|
package/src/config/paths.ts
CHANGED
|
@@ -2,6 +2,7 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
export const AGENT_BOT_HOME_ENV = "AGENT_BOT_HOME";
|
|
5
|
+
export const AGENT_BOT_EXPLICIT_PROFILE_ENV = "AGENT_BOT_EXPLICIT_PROFILE";
|
|
5
6
|
export const DEFAULT_CONFIG_FILE = "config.yaml";
|
|
6
7
|
export const DEFAULT_ENV_FILE = ".env";
|
|
7
8
|
export const DEFAULT_SQLITE_PATH = "./data/agent-bot.sqlite";
|
package/src/config/schema.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const appConfigSchema = z.object({
|
|
|
21
21
|
transport: z.enum(["auto", "sdk", "console"]).default("auto"),
|
|
22
22
|
appId: z.string().optional(),
|
|
23
23
|
appSecret: z.string().optional(),
|
|
24
|
+
userOpenId: z.string().optional(),
|
|
24
25
|
useConsoleWhenMissingCredentials: z.boolean().default(false),
|
|
25
26
|
}),
|
|
26
27
|
agents: z.record(z.string().min(1), agentConfigSchema).refine(
|
|
@@ -33,8 +33,9 @@ export interface StartupStatusView {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export interface SafeRestartStatusView {
|
|
36
|
+
scheduleId: number;
|
|
36
37
|
reason: string;
|
|
37
|
-
phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting";
|
|
38
|
+
phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting" | "cancelled";
|
|
38
39
|
remainingMs?: number;
|
|
39
40
|
pendingFinalDeliveries: number;
|
|
40
41
|
waitingTasks: Array<{
|
|
@@ -270,6 +271,8 @@ export class CardRenderer {
|
|
|
270
271
|
? `${Math.max(0, Math.ceil((view.remainingMs ?? 0) / 1_000))}s`
|
|
271
272
|
: view.phase === "restarting"
|
|
272
273
|
? "0s"
|
|
274
|
+
: view.phase === "cancelled"
|
|
275
|
+
? "已取消"
|
|
273
276
|
: "等待阻塞项清空后开始";
|
|
274
277
|
const status = view.phase === "waiting_tasks"
|
|
275
278
|
? "🟠 等待任务完成"
|
|
@@ -277,7 +280,9 @@ export class CardRenderer {
|
|
|
277
280
|
? "🟠 等待最终结果投递"
|
|
278
281
|
: view.phase === "countdown"
|
|
279
282
|
? "🟡 空闲确认中"
|
|
280
|
-
:
|
|
283
|
+
: view.phase === "restarting"
|
|
284
|
+
? "🔄 正在重启"
|
|
285
|
+
: "⚪ 已取消";
|
|
281
286
|
const lines = [
|
|
282
287
|
`**状态**:${status}`,
|
|
283
288
|
`**重启原因**:${inlineCode(view.reason)}`,
|
|
@@ -296,7 +301,24 @@ export class CardRenderer {
|
|
|
296
301
|
} else {
|
|
297
302
|
elements.push({ tag: "hr" }, markdown("**当前等待的任务**:无"));
|
|
298
303
|
}
|
|
299
|
-
|
|
304
|
+
if (view.phase !== "restarting" && view.phase !== "cancelled") {
|
|
305
|
+
elements.push(
|
|
306
|
+
{ tag: "hr" },
|
|
307
|
+
taskActionRow([{
|
|
308
|
+
text: "Cancel",
|
|
309
|
+
value: {
|
|
310
|
+
action: "safe_restart_cancel",
|
|
311
|
+
scheduleId: String(view.scheduleId),
|
|
312
|
+
},
|
|
313
|
+
}]),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const template = view.phase === "restarting"
|
|
317
|
+
? "blue"
|
|
318
|
+
: view.phase === "cancelled"
|
|
319
|
+
? "grey"
|
|
320
|
+
: "orange";
|
|
321
|
+
return sectionCard("Agent Bot 安全重启", elements, template);
|
|
300
322
|
}
|
|
301
323
|
|
|
302
324
|
renderStartupStatus(view: StartupStatusView): Record<string, unknown> {
|
|
@@ -3,18 +3,11 @@ import type { AppConfig } from "../config/schema.js";
|
|
|
3
3
|
import { threadContextKey } from "./contextKey.js";
|
|
4
4
|
import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
|
|
5
5
|
|
|
6
|
-
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
7
|
-
|
|
8
|
-
interface FeishuConnectorOptions {
|
|
9
|
-
connectTimeoutMs?: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
6
|
export class FeishuConnector {
|
|
13
7
|
constructor(
|
|
14
8
|
private readonly config: AppConfig,
|
|
15
9
|
private readonly handler: FeishuEventHandler,
|
|
16
10
|
private readonly logger: Logger,
|
|
17
|
-
private readonly options: FeishuConnectorOptions = {},
|
|
18
11
|
) {}
|
|
19
12
|
|
|
20
13
|
async start(): Promise<void> {
|
|
@@ -83,86 +76,12 @@ export class FeishuConnector {
|
|
|
83
76
|
};
|
|
84
77
|
},
|
|
85
78
|
});
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
loggerLevel: lark.LoggerLevel.debug,
|
|
93
|
-
});
|
|
94
|
-
await wsClient.start({ eventDispatcher });
|
|
95
|
-
await connected.promise;
|
|
96
|
-
this.logger.info("Feishu WebSocket connector connected.");
|
|
97
|
-
} finally {
|
|
98
|
-
connected.dispose();
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function createConnectionWaiter(
|
|
104
|
-
logger: Logger,
|
|
105
|
-
timeoutMs: number,
|
|
106
|
-
): {
|
|
107
|
-
promise: Promise<void>;
|
|
108
|
-
sdkLogger: {
|
|
109
|
-
error: (...messages: unknown[]) => void;
|
|
110
|
-
warn: (...messages: unknown[]) => void;
|
|
111
|
-
info: (...messages: unknown[]) => void;
|
|
112
|
-
debug: (...messages: unknown[]) => void;
|
|
113
|
-
trace: (...messages: unknown[]) => void;
|
|
114
|
-
};
|
|
115
|
-
dispose: () => void;
|
|
116
|
-
} {
|
|
117
|
-
let resolveConnected!: () => void;
|
|
118
|
-
let rejectConnected!: (error: Error) => void;
|
|
119
|
-
let lastError: string | undefined;
|
|
120
|
-
let settled = false;
|
|
121
|
-
const promise = new Promise<void>((resolve, reject) => {
|
|
122
|
-
resolveConnected = resolve;
|
|
123
|
-
rejectConnected = reject;
|
|
124
|
-
});
|
|
125
|
-
const timer = setTimeout(() => {
|
|
126
|
-
if (settled) return;
|
|
127
|
-
settled = true;
|
|
128
|
-
const detail = lastError ? ` Last SDK error: ${lastError}` : "";
|
|
129
|
-
rejectConnected(new Error(`Timed out after ${timeoutMs}ms waiting for the Feishu WebSocket connection.${detail}`));
|
|
130
|
-
}, timeoutMs);
|
|
131
|
-
const logValues = (messages: unknown[]): string => messages.map(formatSdkLogValue).join(" ");
|
|
132
|
-
const sdkLogger = {
|
|
133
|
-
error: (...messages: unknown[]) => {
|
|
134
|
-
lastError = logValues(messages);
|
|
135
|
-
logger.error({ component: "feishu-websocket", sdkMessage: lastError }, "Feishu SDK error.");
|
|
136
|
-
},
|
|
137
|
-
warn: (...messages: unknown[]) => {
|
|
138
|
-
logger.warn({ component: "feishu-websocket", sdkMessage: logValues(messages) }, "Feishu SDK warning.");
|
|
139
|
-
},
|
|
140
|
-
info: (...messages: unknown[]) => {
|
|
141
|
-
logger.debug({ component: "feishu-websocket", sdkMessage: logValues(messages) }, "Feishu SDK status.");
|
|
142
|
-
},
|
|
143
|
-
debug: (...messages: unknown[]) => {
|
|
144
|
-
if (!messages.some((message) => message === "ws connect success")) return;
|
|
145
|
-
if (settled) return;
|
|
146
|
-
settled = true;
|
|
147
|
-
clearTimeout(timer);
|
|
148
|
-
resolveConnected();
|
|
149
|
-
},
|
|
150
|
-
trace: () => undefined,
|
|
151
|
-
};
|
|
152
|
-
return {
|
|
153
|
-
promise,
|
|
154
|
-
sdkLogger,
|
|
155
|
-
dispose: () => clearTimeout(timer),
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function formatSdkLogValue(value: unknown): string {
|
|
160
|
-
if (typeof value === "string") return value;
|
|
161
|
-
if (value instanceof Error) return value.message;
|
|
162
|
-
try {
|
|
163
|
-
return JSON.stringify(value);
|
|
164
|
-
} catch {
|
|
165
|
-
return String(value);
|
|
79
|
+
const wsClient = new lark.WSClient({
|
|
80
|
+
appId,
|
|
81
|
+
appSecret,
|
|
82
|
+
});
|
|
83
|
+
await wsClient.start({ eventDispatcher });
|
|
84
|
+
this.logger.info("Feishu WebSocket connector started.");
|
|
166
85
|
}
|
|
167
86
|
}
|
|
168
87
|
|
package/src/feishu/transport.ts
CHANGED
|
@@ -8,7 +8,7 @@ type FeishuTransportConfig = Pick<
|
|
|
8
8
|
export function requireServerFeishuTransport(config: FeishuTransportConfig): "sdk" {
|
|
9
9
|
if (!config.appId || !config.appSecret) {
|
|
10
10
|
throw new Error(
|
|
11
|
-
"
|
|
11
|
+
"The Lark bot is not configured. Run agent-bot init first, or use agent-bot console for local-only testing.",
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
14
|
return "sdk";
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,11 @@ import { STOP_EXIT_CODE } from "./supervision/restartPolicy.js";
|
|
|
29
29
|
import { replacementSupervisorEnvironment } from "./supervision/replacementSupervisor.js";
|
|
30
30
|
import { SafeRestartScheduler } from "./supervision/SafeRestartScheduler.js";
|
|
31
31
|
import { SafeRestartNotifier } from "./supervision/SafeRestartNotifier.js";
|
|
32
|
+
import {
|
|
33
|
+
nodeDiagnosticReportArguments,
|
|
34
|
+
prepareCrashReportDirectory,
|
|
35
|
+
resolveSupervisorDiagnosticsPaths,
|
|
36
|
+
} from "./supervision/SupervisorDiagnostics.js";
|
|
32
37
|
|
|
33
38
|
const processStartedAt = new Date();
|
|
34
39
|
const supervised = process.env.AGENT_BOT_SUPERVISED === "1";
|
|
@@ -78,6 +83,7 @@ if (feishuOutbound) {
|
|
|
78
83
|
defaultAgentTitle: defaultAgent?.title ?? defaultAgentName,
|
|
79
84
|
cwd: path.resolve(config.defaults.cwd),
|
|
80
85
|
workspaceKind: defaultAgent?.kind === "codex" ? "projectless" : "project",
|
|
86
|
+
defaultUserOpenId: config.feishu.userOpenId,
|
|
81
87
|
}, metadataHydrator);
|
|
82
88
|
safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger);
|
|
83
89
|
}
|
|
@@ -96,16 +102,17 @@ if (routes.length === 0) throw new Error("Neither Feishu nor console input is en
|
|
|
96
102
|
const outbound = new OutboundRouter(routes);
|
|
97
103
|
let shuttingDown = false;
|
|
98
104
|
let restartRequested = false;
|
|
99
|
-
const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
|
|
100
|
-
supervised,
|
|
101
|
-
restart: requestRestart,
|
|
102
|
-
});
|
|
103
105
|
const safeRestart = new SafeRestartScheduler({
|
|
104
106
|
readActivity: () => store.getServerActivityState(),
|
|
105
107
|
onReady: (reason) => initiateRestart(reason),
|
|
106
108
|
onStatus: (status) => safeRestartNotifier?.update(status),
|
|
107
109
|
onStatusError: (error) => logger.warn({ error }, "Failed to publish safe restart status."),
|
|
108
110
|
});
|
|
111
|
+
const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
|
|
112
|
+
supervised,
|
|
113
|
+
restart: requestRestart,
|
|
114
|
+
cancelSafeRestart: (scheduleId) => safeRestart.cancelScheduled(scheduleId),
|
|
115
|
+
});
|
|
109
116
|
|
|
110
117
|
if (feishuOutbound) feishuConnector = new FeishuConnector(config, controller, logger);
|
|
111
118
|
if (consoleEnabled) consoleConnector = new ConsoleConnector(controller, logger);
|
|
@@ -170,6 +177,7 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
|
|
|
170
177
|
pid: process.pid,
|
|
171
178
|
startedAt: processStartedAt.toISOString(),
|
|
172
179
|
supervised,
|
|
180
|
+
feishuAppId: config.feishu.appId ?? null,
|
|
173
181
|
safeRestartScheduled: safeRestart.scheduled,
|
|
174
182
|
safeRestartReason: safeRestart.pendingReason,
|
|
175
183
|
activity: store.getServerActivityState(),
|
|
@@ -181,16 +189,16 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
|
|
|
181
189
|
return {
|
|
182
190
|
ok: true,
|
|
183
191
|
message: newlyScheduled
|
|
184
|
-
? "
|
|
185
|
-
: "
|
|
192
|
+
? "Safe restart scheduled. It will run after all tasks finish and the server stays idle for 15 seconds."
|
|
193
|
+
: "A safe restart is already pending. Its reason has been updated.",
|
|
186
194
|
};
|
|
187
195
|
}
|
|
188
196
|
setTimeout(() => void initiateRestart(request.reason), 25);
|
|
189
|
-
return { ok: true, message: "
|
|
197
|
+
return { ok: true, message: "Immediate restart requested." };
|
|
190
198
|
case "server_stop":
|
|
191
199
|
safeRestart.cancel();
|
|
192
200
|
setTimeout(() => void shutdown(STOP_EXIT_CODE), 25);
|
|
193
|
-
return { ok: true, message: "
|
|
201
|
+
return { ok: true, message: "Agent Bot server stop requested." };
|
|
194
202
|
case "task_status":
|
|
195
203
|
return { ok: true, data: await controller.controlGetTaskStatus(request.localSessionId) };
|
|
196
204
|
case "task_stop":
|
|
@@ -224,7 +232,12 @@ async function shutdown(exitCode: number, restartReason?: string): Promise<void>
|
|
|
224
232
|
|
|
225
233
|
function startReplacementSupervisor(restartReason: string): void {
|
|
226
234
|
const supervisorEntry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
|
|
227
|
-
const
|
|
235
|
+
const reportDirectory = resolveSupervisorDiagnosticsPaths(config).crashReportDirectory;
|
|
236
|
+
prepareCrashReportDirectory(reportDirectory);
|
|
237
|
+
const supervisor = spawn(process.execPath, [
|
|
238
|
+
...nodeDiagnosticReportArguments(reportDirectory),
|
|
239
|
+
supervisorEntry,
|
|
240
|
+
], {
|
|
228
241
|
cwd: process.cwd(),
|
|
229
242
|
detached: true,
|
|
230
243
|
windowsHide: true,
|
|
@@ -123,6 +123,7 @@ interface CreatedFeishuGroupContext {
|
|
|
123
123
|
export interface ProxyLifecycle {
|
|
124
124
|
supervised?: boolean;
|
|
125
125
|
restart(contextKey: string): Promise<void>;
|
|
126
|
+
cancelSafeRestart?(scheduleId: number): Promise<boolean>;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
|
|
@@ -176,9 +177,8 @@ export class ProxySessionController {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
async onMessage(message: IncomingMessage): Promise<void> {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
180
|
+
// The durable deduplication claim is the only operation allowed before acknowledgement.
|
|
181
|
+
// It prevents event retries from adding duplicate reactions.
|
|
182
182
|
if (!this.store.claimInboundEvent(message.messageId, "message")) return;
|
|
183
183
|
try {
|
|
184
184
|
const reactionId = await this.outbound.addReaction(
|
|
@@ -195,6 +195,9 @@ export class ProxySessionController {
|
|
|
195
195
|
"Failed to acknowledge the incoming Feishu message with a reaction.",
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
|
+
if (message.chatId && message.chatType) {
|
|
199
|
+
this.store.recordChatContext(baseChatContextKey(message.contextKey), message.chatType);
|
|
200
|
+
}
|
|
198
201
|
const replyTarget = message.replyInThread
|
|
199
202
|
? { messageId: message.messageId, replyInThread: true as const }
|
|
200
203
|
: undefined;
|
|
@@ -323,6 +326,18 @@ export class ProxySessionController {
|
|
|
323
326
|
await this.cancelSession(this.requireSession(contextKey, sessionId));
|
|
324
327
|
} else if (kind === "queued_prompt_cancel") {
|
|
325
328
|
await this.cancelQueuedPrompt(scopedAction);
|
|
329
|
+
} else if (kind === "safe_restart_cancel") {
|
|
330
|
+
const scheduleId = Number(scopedAction.value.scheduleId);
|
|
331
|
+
if (!Number.isSafeInteger(scheduleId) || scheduleId <= 0) {
|
|
332
|
+
throw new Error("安全重启卡片无效,请使用最新的状态卡片。");
|
|
333
|
+
}
|
|
334
|
+
if (!this.lifecycle?.cancelSafeRestart) {
|
|
335
|
+
throw new Error("当前运行方式不支持取消安全重启。");
|
|
336
|
+
}
|
|
337
|
+
const cancelled = await this.lifecycle.cancelSafeRestart(scheduleId);
|
|
338
|
+
if (!cancelled) {
|
|
339
|
+
await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
|
|
340
|
+
}
|
|
326
341
|
} else if (kind === "session_more") {
|
|
327
342
|
await this.refreshSessionsCardFromAction(scopedAction, undefined, true);
|
|
328
343
|
} else if (kind === "session_switch") {
|
|
@@ -400,9 +415,9 @@ export class ProxySessionController {
|
|
|
400
415
|
|
|
401
416
|
async controlStopTask(localSessionId: string): Promise<string> {
|
|
402
417
|
const record = this.store.getSession(localSessionId);
|
|
403
|
-
if (!record || record.status === "closed") throw new Error(
|
|
418
|
+
if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
|
|
404
419
|
await this.cancelSession(record);
|
|
405
|
-
return
|
|
420
|
+
return `Task stop requested: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
|
|
406
421
|
}
|
|
407
422
|
|
|
408
423
|
async controlGetTaskStatus(localSessionId: string): Promise<{
|
|
@@ -411,7 +426,7 @@ export class ProxySessionController {
|
|
|
411
426
|
remote?: RemoteSessionSummary;
|
|
412
427
|
}> {
|
|
413
428
|
const record = this.store.getSession(localSessionId);
|
|
414
|
-
if (!record) throw new Error(
|
|
429
|
+
if (!record) throw new Error(`Task not found: ${localSessionId}`);
|
|
415
430
|
let remote: RemoteSessionSummary | undefined;
|
|
416
431
|
if (record.remoteSessionId) {
|
|
417
432
|
const runtime = this.runtimes.forAgent(this.ensureAgent(record.agentName));
|
|
@@ -432,9 +447,9 @@ export class ProxySessionController {
|
|
|
432
447
|
|
|
433
448
|
async controlSetTaskTitle(localSessionId: string, title: string): Promise<string> {
|
|
434
449
|
const normalizedTitle = normalizeTaskTitle(title);
|
|
435
|
-
if (!normalizedTitle) throw new Error("
|
|
450
|
+
if (!normalizedTitle) throw new Error("The task title cannot be empty.");
|
|
436
451
|
const record = this.store.getSession(localSessionId);
|
|
437
|
-
if (!record || record.status === "closed") throw new Error(
|
|
452
|
+
if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
|
|
438
453
|
const loaded = await this.loadSession(record);
|
|
439
454
|
if (loaded.runtime.setTitle) await loaded.runtime.setTitle(record.localSessionId, normalizedTitle);
|
|
440
455
|
else loaded.session.title = normalizedTitle;
|
|
@@ -445,14 +460,14 @@ export class ProxySessionController {
|
|
|
445
460
|
title: normalizedTitle,
|
|
446
461
|
source: "cli",
|
|
447
462
|
});
|
|
448
|
-
return
|
|
463
|
+
return `Task title changed to: ${normalizedTitle}`;
|
|
449
464
|
}
|
|
450
465
|
|
|
451
466
|
async controlSendTaskPrompt(localSessionId: string, text: string): Promise<string> {
|
|
452
467
|
const promptText = text.trim();
|
|
453
|
-
if (!promptText) throw new Error("Prompt
|
|
468
|
+
if (!promptText) throw new Error("The Prompt cannot be empty.");
|
|
454
469
|
const record = this.store.getSession(localSessionId);
|
|
455
|
-
if (!record || record.status === "closed") throw new Error(
|
|
470
|
+
if (!record || record.status === "closed") throw new Error(`Task not found: ${localSessionId}`);
|
|
456
471
|
const runtime = this.runtimes.forAgent(this.ensureAgent(record.agentName));
|
|
457
472
|
const activeTurnId = runtime.getSession(localSessionId)?.activeTurnId;
|
|
458
473
|
const routedContextKey = this.outbound.getSessionContextKey(localSessionId);
|
|
@@ -472,7 +487,7 @@ export class ProxySessionController {
|
|
|
472
487
|
?? lastSnapshot?.replyTarget
|
|
473
488
|
?? routedReplyTarget;
|
|
474
489
|
if (isThreadContextKey(responseContextKey) && !existingReplyTarget) {
|
|
475
|
-
throw new Error("
|
|
490
|
+
throw new Error("Could not resolve the target task's thread reply location. The Prompt was not sent.");
|
|
476
491
|
}
|
|
477
492
|
const scopedRecord = this.store.getSessionForContext(localSessionId, responseContextKey)
|
|
478
493
|
?? { ...record, contextKey: responseContextKey };
|
|
@@ -506,7 +521,7 @@ export class ProxySessionController {
|
|
|
506
521
|
} finally {
|
|
507
522
|
if (this.messageQueues.get(responseContextKey) === next) this.messageQueues.delete(responseContextKey);
|
|
508
523
|
}
|
|
509
|
-
return
|
|
524
|
+
return `The Prompt was posted to the original chat and submitted to the task: ${record.title ?? record.remoteSessionId ?? record.localSessionId}`;
|
|
510
525
|
}
|
|
511
526
|
|
|
512
527
|
private cardActionContextKey(action: CardAction): string {
|
|
@@ -2,19 +2,25 @@ import type { Logger } from "pino";
|
|
|
2
2
|
import { detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
|
|
3
3
|
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
4
4
|
import type { FeishuOutbound } from "../feishu/types.js";
|
|
5
|
-
import type { SessionRecord, StateStore } from "../state/StateStore.js";
|
|
5
|
+
import type { SessionRecord, StateStore, UserContextRecord } from "../state/StateStore.js";
|
|
6
6
|
|
|
7
7
|
export interface StartupNotificationOptions {
|
|
8
8
|
defaultAgentName: string;
|
|
9
9
|
defaultAgentTitle: string;
|
|
10
10
|
cwd: string;
|
|
11
11
|
workspaceKind?: "project" | "projectless";
|
|
12
|
+
defaultUserOpenId?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface StartupTaskMetadataHydrator {
|
|
15
16
|
hydrate(session: SessionRecord): Promise<SessionRecord>;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
interface StartupNotificationTarget {
|
|
20
|
+
contextKey: string;
|
|
21
|
+
context?: UserContextRecord;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export class StartupNotifier {
|
|
19
25
|
constructor(
|
|
20
26
|
private readonly store: StateStore,
|
|
@@ -26,7 +32,7 @@ export class StartupNotifier {
|
|
|
26
32
|
) {}
|
|
27
33
|
|
|
28
34
|
async notify(startedAt: Date, restartReason: string): Promise<void> {
|
|
29
|
-
let targets;
|
|
35
|
+
let targets: StartupNotificationTarget[];
|
|
30
36
|
try {
|
|
31
37
|
const activeGroupSince = new Date(startedAt.getTime() - 3 * 60 * 1_000);
|
|
32
38
|
const chats = [
|
|
@@ -40,10 +46,16 @@ export class StartupNotifier {
|
|
|
40
46
|
}));
|
|
41
47
|
} catch (error) {
|
|
42
48
|
this.logger.warn({ error }, "Failed to load startup notification targets.");
|
|
43
|
-
|
|
49
|
+
throw new Error("Failed to load startup notification targets.", { cause: error });
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
if (targets.length === 0) {
|
|
53
|
+
const defaultUserOpenId = this.options.defaultUserOpenId?.trim();
|
|
54
|
+
if (!defaultUserOpenId) return;
|
|
55
|
+
targets = [{ contextKey: `open_id:${defaultUserOpenId}` }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const deliveries = await Promise.all(targets.map(async ({ contextKey, context }) => {
|
|
47
59
|
let session = context?.currentSessionId ? this.store.getSession(context.currentSessionId) : undefined;
|
|
48
60
|
if (session && this.metadataHydrator) {
|
|
49
61
|
try {
|
|
@@ -81,12 +93,17 @@ export class StartupNotifier {
|
|
|
81
93
|
});
|
|
82
94
|
try {
|
|
83
95
|
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
96
|
+
return true;
|
|
84
97
|
} catch (error) {
|
|
85
98
|
this.logger.warn(
|
|
86
99
|
{ error, contextKey },
|
|
87
100
|
"Failed to send startup status notification.",
|
|
88
101
|
);
|
|
102
|
+
return false;
|
|
89
103
|
}
|
|
90
104
|
}));
|
|
105
|
+
if (deliveries.length > 0 && !deliveries.some(Boolean)) {
|
|
106
|
+
throw new Error("Failed to send any startup status notification.");
|
|
107
|
+
}
|
|
91
108
|
}
|
|
92
109
|
}
|
|
@@ -14,6 +14,7 @@ export class SafeRestartNotifier {
|
|
|
14
14
|
private readonly messageIds = new Map<string, string>();
|
|
15
15
|
private readonly cardHashes = new Map<string, string>();
|
|
16
16
|
private activeScheduleId?: number;
|
|
17
|
+
private terminalScheduleId?: number;
|
|
17
18
|
private pendingInitialStatus?: SafeRestartStatus;
|
|
18
19
|
private initialCardTimer?: NodeJS.Timeout;
|
|
19
20
|
private queue = Promise.resolve();
|
|
@@ -27,13 +28,24 @@ export class SafeRestartNotifier {
|
|
|
27
28
|
) {}
|
|
28
29
|
|
|
29
30
|
update(status: SafeRestartStatus): Promise<void> {
|
|
31
|
+
if (this.terminalScheduleId !== undefined && status.scheduleId <= this.terminalScheduleId) {
|
|
32
|
+
if (status.phase !== "cancelled") return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
if (this.activeScheduleId !== undefined && status.scheduleId < this.activeScheduleId) {
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
}
|
|
37
|
+
if (status.phase === "cancelled") {
|
|
38
|
+
this.terminalScheduleId = Math.max(this.terminalScheduleId ?? 0, status.scheduleId);
|
|
39
|
+
}
|
|
30
40
|
if (this.activeScheduleId !== status.scheduleId) {
|
|
31
41
|
this.activeScheduleId = status.scheduleId;
|
|
32
42
|
this.messageIds.clear();
|
|
33
43
|
this.cardHashes.clear();
|
|
34
44
|
this.clearInitialCardTimer();
|
|
35
45
|
this.pendingInitialStatus = status;
|
|
36
|
-
const delayMs =
|
|
46
|
+
const delayMs = status.phase === "cancelled"
|
|
47
|
+
? 0
|
|
48
|
+
: this.options.initialCardDelayMs ?? DEFAULT_INITIAL_CARD_DELAY_MS;
|
|
37
49
|
if (delayMs <= 0) return this.publishPendingInitialStatus();
|
|
38
50
|
this.initialCardTimer = setTimeout(() => {
|
|
39
51
|
this.initialCardTimer = undefined;
|
|
@@ -79,13 +91,16 @@ export class SafeRestartNotifier {
|
|
|
79
91
|
private async publish(status: SafeRestartStatus): Promise<void> {
|
|
80
92
|
const targets = this.store.listChatContexts("p2p");
|
|
81
93
|
if (targets.length === 0) return;
|
|
82
|
-
const waitingTasks =
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const waitingTasks = status.phase === "cancelled"
|
|
95
|
+
? []
|
|
96
|
+
: this.store.listAllSessions()
|
|
97
|
+
.filter((session) => session.status === "running" || this.store.countQueuedPrompts(session.localSessionId) > 0)
|
|
98
|
+
.map((session) => ({
|
|
99
|
+
id: session.remoteSessionId ?? session.localSessionId,
|
|
100
|
+
title: session.title,
|
|
101
|
+
}));
|
|
88
102
|
const card = this.renderer.renderSafeRestartStatus({
|
|
103
|
+
scheduleId: status.scheduleId,
|
|
89
104
|
reason: status.reason,
|
|
90
105
|
phase: status.phase,
|
|
91
106
|
remainingMs: status.remainingMs,
|
|
@@ -4,7 +4,12 @@ export interface ServerActivityState {
|
|
|
4
4
|
latestInboundAt?: string;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export type SafeRestartPhase =
|
|
7
|
+
export type SafeRestartPhase =
|
|
8
|
+
| "waiting_tasks"
|
|
9
|
+
| "waiting_delivery"
|
|
10
|
+
| "countdown"
|
|
11
|
+
| "restarting"
|
|
12
|
+
| "cancelled";
|
|
8
13
|
|
|
9
14
|
export interface SafeRestartStatus {
|
|
10
15
|
scheduleId: number;
|
|
@@ -56,6 +61,24 @@ export class SafeRestartScheduler {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
cancel(): void {
|
|
64
|
+
this.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async cancelScheduled(expectedScheduleId: number): Promise<boolean> {
|
|
68
|
+
const reason = this.reason;
|
|
69
|
+
if (!reason || this.scheduleId !== expectedScheduleId) return false;
|
|
70
|
+
const activity = this.options.readActivity();
|
|
71
|
+
this.clear();
|
|
72
|
+
await this.emitStatus({
|
|
73
|
+
scheduleId: expectedScheduleId,
|
|
74
|
+
reason,
|
|
75
|
+
phase: "cancelled",
|
|
76
|
+
activity,
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private clear(): void {
|
|
59
82
|
if (this.timer) clearInterval(this.timer);
|
|
60
83
|
this.timer = undefined;
|
|
61
84
|
this.reason = undefined;
|
|
@@ -117,8 +140,9 @@ export class SafeRestartScheduler {
|
|
|
117
140
|
}
|
|
118
141
|
return;
|
|
119
142
|
}
|
|
120
|
-
|
|
121
|
-
this.
|
|
143
|
+
const scheduleId = this.scheduleId;
|
|
144
|
+
this.clear();
|
|
145
|
+
await this.emitStatus({ scheduleId, reason, phase: "restarting", activity: confirmed, remainingMs: 0 });
|
|
122
146
|
await this.options.onReady(reason);
|
|
123
147
|
}
|
|
124
148
|
|