@keyou007/agent-bot 0.1.24-alpha.6 → 0.1.24-alpha.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/CHANGELOG.md +14 -0
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/dist/codex/CodexEventMapper.js +1 -0
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexRuntime.js +46 -6
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/codex/TraePlanEntry.js +70 -0
- package/dist/codex/TraePlanEntry.js.map +1 -0
- package/dist/feishu/CardRenderer.js +27 -1
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +5 -0
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +32 -1
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +3 -0
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/terminal/TerminalCommandTracker.js +74 -0
- package/dist/terminal/TerminalCommandTracker.js.map +1 -0
- package/dist/terminal/TerminalProcess.js +25 -1
- package/dist/terminal/TerminalProcess.js.map +1 -1
- package/dist/terminal/TerminalServer.js +44 -7
- package/dist/terminal/TerminalServer.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +3 -1
- package/src/codex/CodexEventMapper.ts +1 -0
- package/src/codex/CodexRuntime.ts +45 -6
- package/src/codex/TraePlanEntry.ts +63 -0
- package/src/feishu/CardRenderer.ts +30 -2
- package/src/index.ts +14 -3
- package/src/proxy/ProxySessionController.ts +4 -0
- package/src/supervision/SafeRestartNotifier.ts +34 -2
- package/src/supervision/SafeRestartScheduler.ts +7 -0
- package/src/terminal/TerminalCommandTracker.ts +74 -0
- package/src/terminal/TerminalProcess.ts +24 -2
- package/src/terminal/TerminalServer.ts +45 -7
|
@@ -115,7 +115,7 @@ Feishu group renames update only the group's currently bound task title, not top
|
|
|
115
115
|
|
|
116
116
|
Fork creation stores source-task and branch-Turn references without synchronizing the full local Turn list. The Feishu Turn card reuses local records and loads only the summary pages needed for the requested page, not the entire history. `task turns` loads the first page if necessary and returns available local records. This does not change the Agent's inherited context. Retry listing Turns after a temporary history-read failure; unsupported summary pagination never falls back to downloading full history.
|
|
117
117
|
|
|
118
|
-
TraeX plan-mode confirmations follow the task permission mode. With `auto`, Agent Bot automatically accepts Enter Plan Mode and Approve Plan; do not also approve or send a continuation prompt. With `confirm`, `Approve Plan` / `Enter Plan Mode`, `Reject`, and `Cancel Request` require the user's choice. Approval requests and decisions stay in the same thinking card and live Preview, explicitly labeling automatic approvals. If
|
|
118
|
+
TraeX plan-mode confirmations follow the task permission mode. With `auto`, Agent Bot automatically accepts Enter Plan Mode and Approve Plan; do not also approve or send a continuation prompt. With `confirm`, `Approve Plan` / `Enter Plan Mode`, `Reject`, and `Cancel Request` require the user's choice. Approval requests and decisions stay in the same thinking card and live Preview, explicitly labeling automatic approvals. If TraeX reports no pending request after automatically entering Plan mode, Agent Bot verifies the matching completed EnterPlanMode call before recording success. Missing requests alone are not proof of approval. Unverified automatic failures still offer manual confirmation with an error notice; never pretend they succeeded. Approved implementation continues in the same task and card. Explicit discussion-only or no-implementation instructions still apply. Preview cannot approve, cancelling a request does not itself stop the task, and waiting turns still block safe restart. Do not stop or force-restart merely to clear that wait. Missed notifications from older versions are not automatically replayed.
|
|
119
119
|
|
|
120
120
|
## Change Settings
|
|
121
121
|
|
|
@@ -207,6 +207,8 @@ The safe-restart card has matching `Cancel` and `ForceRestart` actions. Clicking
|
|
|
207
207
|
|
|
208
208
|
Each displayed running task on that card has a `Stop` action targeting its exact task, even across conversations. It uses normal Agent interruption and leaves the restart scheduled; the card updates as tasks stop. Only stop a task when the user requests it.
|
|
209
209
|
|
|
210
|
+
Blocking terminals are listed on the safe-restart card by their eight-character token, directory, activity/connection state, and current command when available. Activity means a connected foreground page, a known running command, or input/output within 30 seconds; inactive Shells still block restart. PowerShell/PSReadLine reports commands; other Shells may report only a foreground process or unknown. The owner-only End Terminal action stops exactly that Shell and waits for its exit without forcing restart. Never end a user's terminal merely to complete your own restart; require their explicit request. Stale/cancelled cards cannot close terminals.
|
|
211
|
+
|
|
210
212
|
A bare `agentbot server restart` invoked inside an Agent Bot-started Agent returns restart status to the source task conversation. The same command from an ordinary terminal uses the configured user's private chat. Add `--task <task>` only to override either default.
|
|
211
213
|
|
|
212
214
|
Never use `taskkill`, `Stop-Process`, or equivalent commands for routine restart management.
|
|
@@ -4,6 +4,7 @@ import type { PlanStep, ToolState } from "../runtime/types.js";
|
|
|
4
4
|
const modeChangeRequest = z.object({
|
|
5
5
|
requestId: z.string().min(1),
|
|
6
6
|
targetMode: z.enum(["plan", "default"]),
|
|
7
|
+
sourceToolCallId: z.string().min(1).nullish(),
|
|
7
8
|
reason: z.string(),
|
|
8
9
|
allowedPrompts: z.array(z.object({ tool: z.string(), prompt: z.string() })).optional(),
|
|
9
10
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
3
|
import type { Logger } from "pino";
|
|
3
4
|
import type {
|
|
4
5
|
AgentRuntime,
|
|
@@ -34,6 +35,7 @@ import { AppServerRequestError } from "./AppServerConnection.js";
|
|
|
34
35
|
import { workspaceSandboxFromConfig, workspaceSandboxFromResponse, type TurnSandboxPolicy, type WorkspaceSandboxPolicy } from "./AppServerPermissions.js";
|
|
35
36
|
import { formatCodexError, mapCodexNotification } from "./CodexEventMapper.js";
|
|
36
37
|
import { CodexLocalActivityDetector } from "./CodexLocalActivityDetector.js";
|
|
38
|
+
import { hasCompletedTraePlanEntry } from "./TraePlanEntry.js";
|
|
37
39
|
import { detectProjectlessWorkspace } from "./ProjectlessWorkspace.js";
|
|
38
40
|
import { threadWriterLockPath } from "./ThreadWriterProcess.js";
|
|
39
41
|
import { assertProviderSettingsApplied, PROVIDER_SWITCH_BUSY, PROVIDER_SWITCH_EMPTY, ProviderSwitchError, providerSwitchFailure } from "./ProviderSwitch.js";
|
|
@@ -110,6 +112,7 @@ interface PendingModeChange {
|
|
|
110
112
|
turnId: string;
|
|
111
113
|
requestId: string;
|
|
112
114
|
targetMode: "plan" | "default";
|
|
115
|
+
sourceToolCallId?: string;
|
|
113
116
|
responding: boolean;
|
|
114
117
|
decision?: ApprovalDecision;
|
|
115
118
|
automatic?: boolean;
|
|
@@ -950,11 +953,15 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
950
953
|
try {
|
|
951
954
|
const client = await this.client();
|
|
952
955
|
if (this.modeChanges.get(requestId) !== modeChange) throw new Error("Approval request is no longer pending.");
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
956
|
+
try {
|
|
957
|
+
await client.request("mode/change/respond", {
|
|
958
|
+
threadId: this.requireSession(sessionId).remoteSessionId,
|
|
959
|
+
requestId: modeChange.requestId,
|
|
960
|
+
decision: decision === "accept" ? "approved" : decision === "decline" ? "rejected" : "cancelled",
|
|
961
|
+
}, CONTROL_REQUEST_TIMEOUT_MS);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (!await this.reconcileCompletedPlanEntry(requestId, modeChange, error)) throw error;
|
|
964
|
+
}
|
|
958
965
|
if (this.modeChanges.get(requestId) === modeChange) {
|
|
959
966
|
this.confirmPlanContinuation(modeChange, decision);
|
|
960
967
|
this.modeChanges.delete(requestId);
|
|
@@ -974,6 +981,36 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
974
981
|
this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
|
|
975
982
|
}
|
|
976
983
|
|
|
984
|
+
private async reconcileCompletedPlanEntry(
|
|
985
|
+
requestId: string, pending: PendingModeChange, error: unknown,
|
|
986
|
+
): Promise<boolean> {
|
|
987
|
+
const session = this.sessions.get(pending.sessionId);
|
|
988
|
+
if (!pending.automatic || pending.decision !== "accept" || pending.targetMode !== "plan"
|
|
989
|
+
|| this.provider.getAgentFamily?.() !== "traex" || !session?.rolloutPath
|
|
990
|
+
|| !(error instanceof AppServerRequestError) || error.method !== "mode/change/respond" || error.code !== -32600
|
|
991
|
+
|| error.serverMessage !== `failed to respond to mode change request: No pending mode change found for request_id: ${pending.requestId}`) {
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
const toolCallId = pending.sourceToolCallId ?? pending.requestId;
|
|
995
|
+
for (const waitMs of [0, 100, 200]) {
|
|
996
|
+
if (waitMs) await delay(waitMs);
|
|
997
|
+
if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
|
|
998
|
+
|| session.activeTurnId !== pending.turnId) return false;
|
|
999
|
+
try {
|
|
1000
|
+
if (!await hasCompletedTraePlanEntry(session.rolloutPath, pending.turnId, toolCallId)) continue;
|
|
1001
|
+
if (this.modeChanges.get(requestId) !== pending || this.sessions.get(pending.sessionId) !== session
|
|
1002
|
+
|| session.activeTurnId !== pending.turnId) return false;
|
|
1003
|
+
this.logger.info({ sessionId: pending.sessionId, turnId: pending.turnId, requestId, toolCallId },
|
|
1004
|
+
"Verified that TraeX already completed the automatic plan-mode entry.");
|
|
1005
|
+
return true;
|
|
1006
|
+
} catch (verificationError) {
|
|
1007
|
+
this.logger.debug({ error: verificationError, sessionId: pending.sessionId, requestId },
|
|
1008
|
+
"Could not verify TraeX plan-mode entry from the rollout tail.");
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
977
1014
|
async listModels(modelProvider?: string, fallbackModel?: string): Promise<ModelOption[]> {
|
|
978
1015
|
return (await this.listModelsWithStatus(modelProvider, fallbackModel)).models;
|
|
979
1016
|
}
|
|
@@ -1217,6 +1254,7 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
1217
1254
|
const pending: PendingModeChange = {
|
|
1218
1255
|
sessionId, turnId: mapped.turnId, requestId: mapped.requestId,
|
|
1219
1256
|
targetMode: mapped.targetMode, responding: false,
|
|
1257
|
+
sourceToolCallId: mapped.sourceToolCallId ?? undefined,
|
|
1220
1258
|
};
|
|
1221
1259
|
this.modeChanges.set(requestId, pending);
|
|
1222
1260
|
const automatic = session.permissionMode === "auto" && this.provider.getAgentFamily?.() === "traex";
|
|
@@ -1236,8 +1274,9 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
1236
1274
|
this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId, request });
|
|
1237
1275
|
if (automatic) {
|
|
1238
1276
|
void this.resolveApproval(sessionId, requestId, "accept", true).catch((error: unknown) => {
|
|
1239
|
-
this.logger.warn({ error, sessionId, requestId }, "Failed to automatically approve the TraeX mode change.");
|
|
1240
1277
|
if (this.modeChanges.get(requestId) !== pending || session.activeTurnId !== mapped.turnId) return;
|
|
1278
|
+
this.logger.warn({ error, sessionId, requestId, turnId: mapped.turnId, targetMode: mapped.targetMode,
|
|
1279
|
+
sourceToolCallId: pending.sourceToolCallId }, "Failed to automatically approve the TraeX mode change.");
|
|
1241
1280
|
this.emit({ type: "approval_requested", sessionId, turnId: mapped.turnId,
|
|
1242
1281
|
request: { ...request, automatic: false, reason: ["自动同意失败,请手动确认。", request.reason].filter(Boolean).join("\n\n") } });
|
|
1243
1282
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
const MAX_TAIL_BYTES = 256 * 1024;
|
|
5
|
+
const mutation = z.object({
|
|
6
|
+
type: z.literal("history_mutation"),
|
|
7
|
+
payload: z.object({
|
|
8
|
+
version: z.literal(1),
|
|
9
|
+
turn_id: z.string(),
|
|
10
|
+
operation: z.string(),
|
|
11
|
+
items: z.array(z.object({
|
|
12
|
+
type: z.string(),
|
|
13
|
+
call_id: z.string().optional(),
|
|
14
|
+
name: z.string().optional(),
|
|
15
|
+
output: z.unknown().optional(),
|
|
16
|
+
})).optional(),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// TraeX can finish EnterPlanMode without a mode/changeResolved notification.
|
|
21
|
+
// Read only a bounded tail, and require the exact call and its successful result.
|
|
22
|
+
export async function hasCompletedTraePlanEntry(
|
|
23
|
+
rolloutPath: string, turnId: string, toolCallId: string,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
const file = await open(rolloutPath, "r");
|
|
26
|
+
try {
|
|
27
|
+
const { size } = await file.stat();
|
|
28
|
+
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
|
29
|
+
const buffer = Buffer.alloc(size - start);
|
|
30
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
|
|
31
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
32
|
+
const lines = text.split("\n");
|
|
33
|
+
if (start > 0) lines.shift();
|
|
34
|
+
lines.pop(); // Ignore an uncommitted/partial final record.
|
|
35
|
+
let entered = false;
|
|
36
|
+
let matchedCall = false;
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
let value: unknown;
|
|
39
|
+
try { value = JSON.parse(line); } catch { continue; }
|
|
40
|
+
const parsed = mutation.safeParse(value);
|
|
41
|
+
if (!parsed.success || parsed.data.payload.turn_id !== turnId) continue;
|
|
42
|
+
const payload = parsed.data.payload;
|
|
43
|
+
if (payload.operation !== "append") {
|
|
44
|
+
matchedCall = false;
|
|
45
|
+
entered = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const item of payload.items ?? []) {
|
|
49
|
+
if (item.call_id !== toolCallId) continue;
|
|
50
|
+
if (item.type === "function_call") {
|
|
51
|
+
matchedCall = item.name === "EnterPlanMode";
|
|
52
|
+
entered = false;
|
|
53
|
+
} else if (item.type === "function_call_output") {
|
|
54
|
+
entered = matchedCall && typeof item.output === "string"
|
|
55
|
+
&& item.output.startsWith("Entered plan mode.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return entered;
|
|
60
|
+
} finally {
|
|
61
|
+
await file.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
|
|
16
16
|
import { truncateMiddle, truncateText } from "../utils/markdown.js";
|
|
17
17
|
import type { UpdateNotice } from "../updates/types.js";
|
|
18
|
-
import type { TerminalCardView } from "../terminal/TerminalServer.js";
|
|
18
|
+
import type { ActiveTerminal, TerminalCardView } from "../terminal/TerminalServer.js";
|
|
19
19
|
import { localCardImage } from "./LocalCardImage.js";
|
|
20
20
|
|
|
21
21
|
export interface StartupStatusView {
|
|
@@ -61,6 +61,8 @@ export interface InitializationWelcomeView {
|
|
|
61
61
|
|
|
62
62
|
export interface SafeRestartStatusView {
|
|
63
63
|
activeTerminals?: number;
|
|
64
|
+
terminalBlockers?: ActiveTerminal[];
|
|
65
|
+
contextKey?: string;
|
|
64
66
|
scheduleId: number;
|
|
65
67
|
reason: string;
|
|
66
68
|
phase: "waiting_tasks" | "waiting_delivery" | "countdown" | "restarting" | "cancelled" | "superseded";
|
|
@@ -1069,9 +1071,35 @@ export class CardRenderer {
|
|
|
1069
1071
|
`**重启原因**:${inlineCode(view.reason)}`,
|
|
1070
1072
|
`**重启倒计时**:${countdown}`,
|
|
1071
1073
|
`**待投递结果**:${view.pendingFinalDeliveries} 条`,
|
|
1072
|
-
...(view.activeTerminals ? [`**活动终端**:${view.activeTerminals}
|
|
1074
|
+
...(view.activeTerminals ? [`**活动终端**:${view.activeTerminals} 个。结束终端会中断其中的命令;ForceRestart 会中断所有终端。`] : []),
|
|
1073
1075
|
];
|
|
1074
1076
|
const elements: Record<string, unknown>[] = [markdown(lines.join("\n"))];
|
|
1077
|
+
if (view.terminalBlockers?.length) {
|
|
1078
|
+
elements.push({ tag: "hr" }, markdown(`**阻止重启的终端(${view.terminalBlockers.length})**`));
|
|
1079
|
+
for (const terminal of view.terminalBlockers) {
|
|
1080
|
+
const command = terminal.state === "closing" ? "正在结束,等待 Shell 退出"
|
|
1081
|
+
: terminal.state === "starting" ? "Shell 启动中"
|
|
1082
|
+
: terminal.command.status === "idle" ? "无(Shell 空闲)"
|
|
1083
|
+
: terminal.command.command ? inlineCode(escapeCardHtml(truncateText(terminal.command.command.replace(/\s+/gu, " "), 240)))
|
|
1084
|
+
: terminal.command.status === "running" ? "命令执行中(命令内容不可用)"
|
|
1085
|
+
: "未知(Shell 未提供命令信息)";
|
|
1086
|
+
const connection = terminal.connected ? terminal.foreground ? "网页前台" : "网页已连接" : "网页已断开";
|
|
1087
|
+
const label = `**终端**:${inlineCode(terminal.token)} · ${terminal.active ? "活跃" : "不活跃"} · ${connection}\n**目录**:${inlineCode(escapeCardHtml(truncateText(terminal.cwd, 240)))}\n**${terminal.command.source === "process" ? "前台进程" : "正在执行"}**:${command}`;
|
|
1088
|
+
elements.push({
|
|
1089
|
+
tag: "column_set", flex_mode: "none", horizontal_spacing: "8px",
|
|
1090
|
+
columns: [
|
|
1091
|
+
{ tag: "column", width: "weighted", weight: 1, elements: [markdown(label)] },
|
|
1092
|
+
...(actionable && terminal.state !== "closing" ? [{
|
|
1093
|
+
tag: "column", width: "auto", elements: [taskActionElement({
|
|
1094
|
+
text: "结束终端", type: "danger",
|
|
1095
|
+
value: { action: "safe_restart_terminal_stop", scheduleId: String(view.scheduleId), terminalId: terminal.terminalId, ...(view.contextKey ? { contextKey: view.contextKey } : {}) },
|
|
1096
|
+
})],
|
|
1097
|
+
}] : []),
|
|
1098
|
+
],
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
elements.push(markdown("活跃:前台连接、正在执行命令或最近 30 秒有输入输出。不活跃的 Shell 仍会阻止安全重启。"));
|
|
1102
|
+
}
|
|
1075
1103
|
if (view.waitingTasks.length > 0) {
|
|
1076
1104
|
const visible = view.waitingTasks.slice(0, 10);
|
|
1077
1105
|
elements.push({ tag: "hr" }, markdown(`**当前等待的任务(${view.waitingTasks.length})**`));
|
package/src/index.ts
CHANGED
|
@@ -145,7 +145,14 @@ if (feishuOutbound) {
|
|
|
145
145
|
workspaceKind: defaultAgent?.kind === "app-server" ? "projectless" : "project",
|
|
146
146
|
defaultUserOpenId: config.feishu.userOpenId,
|
|
147
147
|
}, metadataHydrator);
|
|
148
|
-
safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger
|
|
148
|
+
safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger, {
|
|
149
|
+
ownerId: () => config.feishu.userOpenId,
|
|
150
|
+
isCurrentSchedule: (scheduleId) => !shuttingDown && !restartRequested && safeRestart.isCurrentSchedule(scheduleId),
|
|
151
|
+
stopTerminal: (terminalId, userId) => {
|
|
152
|
+
if (!terminalServer) throw new Error("网页终端未启用。");
|
|
153
|
+
return terminalServer.stopActiveTerminal(terminalId, userId);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
const consoleEnabled = consoleOnly || config.console.enabled || transport === "console";
|
|
@@ -253,8 +260,8 @@ if (feishuOutbound && localFileViewer && config.terminal?.enabled !== false) {
|
|
|
253
260
|
|
|
254
261
|
function serverActivity() {
|
|
255
262
|
const activity = store.getServerActivityState();
|
|
256
|
-
const
|
|
257
|
-
return { ...activity, activeTerminals };
|
|
263
|
+
const terminalBlockers = terminalServer?.listActiveTerminals() ?? [];
|
|
264
|
+
return { ...activity, activeTerminals: terminalBlockers.length, terminalBlockers };
|
|
258
265
|
}
|
|
259
266
|
|
|
260
267
|
const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
|
|
@@ -264,6 +271,10 @@ const controller = new ProxySessionController(config, store, runtimes, outbound,
|
|
|
264
271
|
if (!terminalServer) throw new Error("网页终端未启用。");
|
|
265
272
|
return terminalServer.resolveApproval(action);
|
|
266
273
|
},
|
|
274
|
+
stopSafeRestartTerminal: (action) => {
|
|
275
|
+
if (!safeRestartNotifier) throw new Error("安全重启通知未启用。");
|
|
276
|
+
return safeRestartNotifier.stopTerminal(action);
|
|
277
|
+
},
|
|
267
278
|
showUpdates: (contextKey, userId) => manualUpdates.show(contextKey, userId),
|
|
268
279
|
selectUpdate: (action, replyTarget) => manualUpdates.select(action, replyTarget),
|
|
269
280
|
restart: requestRestart,
|
|
@@ -515,6 +515,7 @@ export interface ControlTaskReleaseResult {
|
|
|
515
515
|
export interface ProxyLifecycle {
|
|
516
516
|
terminalUrl?(jobId: string): string | undefined;
|
|
517
517
|
authorizeTerminal?(action: CardAction): Promise<string>;
|
|
518
|
+
stopSafeRestartTerminal?(action: CardAction): Promise<string>;
|
|
518
519
|
supervised?: boolean;
|
|
519
520
|
restart(contextKey: string, force: boolean, replyTarget?: MessageReplyTarget): Promise<void>;
|
|
520
521
|
cancelSafeRestart?(scheduleId: number): Promise<boolean>;
|
|
@@ -1035,6 +1036,9 @@ export class ProxySessionController {
|
|
|
1035
1036
|
if (kind === "terminal_authorize") {
|
|
1036
1037
|
if (!this.lifecycle?.authorizeTerminal) throw new Error("网页终端未启用。");
|
|
1037
1038
|
await this.outbound.sendText(contextKey, await this.lifecycle.authorizeTerminal(scopedAction));
|
|
1039
|
+
} else if (kind === "safe_restart_terminal_stop") {
|
|
1040
|
+
if (!this.lifecycle?.stopSafeRestartTerminal) throw new Error("网页终端未启用。");
|
|
1041
|
+
await this.outbound.sendText(contextKey, await this.lifecycle.stopSafeRestartTerminal(scopedAction));
|
|
1038
1042
|
} else if (kind === "agentbot_update_cancel") {
|
|
1039
1043
|
if (!this.lifecycle?.cancelAutomaticUpdate) throw new Error("自动更新功能未启用。");
|
|
1040
1044
|
await this.lifecycle.cancelAutomaticUpdate(scopedAction);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
3
3
|
import { isThreadContextKey } from "../feishu/contextKey.js";
|
|
4
|
-
import type { FeishuOutbound } from "../feishu/types.js";
|
|
4
|
+
import type { CardAction, FeishuOutbound } from "../feishu/types.js";
|
|
5
5
|
import type { StateStore } from "../state/StateStore.js";
|
|
6
6
|
import type { RestartNotificationTarget, SafeRestartStatus } from "./SafeRestartScheduler.js";
|
|
7
7
|
|
|
@@ -9,11 +9,15 @@ const DEFAULT_INITIAL_CARD_DELAY_MS = 3_000;
|
|
|
9
9
|
|
|
10
10
|
export interface SafeRestartNotifierOptions {
|
|
11
11
|
initialCardDelayMs?: number;
|
|
12
|
+
ownerId?(): string | undefined;
|
|
13
|
+
isCurrentSchedule?(scheduleId: number): boolean;
|
|
14
|
+
stopTerminal?(terminalId: string, userId: string): Promise<boolean>;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export class SafeRestartNotifier {
|
|
15
18
|
private readonly messageIds = new Map<string, string>();
|
|
16
19
|
private readonly cardHashes = new Map<string, string>();
|
|
20
|
+
private readonly cardTerminalIds = new Map<string, Set<string>>();
|
|
17
21
|
private readonly notificationTargets = new Map<string, RestartNotificationTarget>();
|
|
18
22
|
private activeScheduleId?: number;
|
|
19
23
|
private terminalScheduleId?: number;
|
|
@@ -55,6 +59,7 @@ export class SafeRestartNotifier {
|
|
|
55
59
|
this.activeScheduleId = status.scheduleId;
|
|
56
60
|
this.messageIds.clear();
|
|
57
61
|
this.cardHashes.clear();
|
|
62
|
+
this.cardTerminalIds.clear();
|
|
58
63
|
this.notificationTargets.clear();
|
|
59
64
|
this.clearInitialCardTimer();
|
|
60
65
|
this.pendingInitialStatus = status;
|
|
@@ -90,6 +95,25 @@ export class SafeRestartNotifier {
|
|
|
90
95
|
return [...this.notificationTargets.values()].map((target) => ({ ...target }));
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
async stopTerminal(action: CardAction): Promise<string> {
|
|
99
|
+
const ownerId = this.options.ownerId?.();
|
|
100
|
+
if (!ownerId || action.userId !== ownerId) throw new Error("仅 AgentBot 所有者可结束终端。");
|
|
101
|
+
const scheduleId = Number(action.value.scheduleId);
|
|
102
|
+
const terminalId = typeof action.value.terminalId === "string" ? action.value.terminalId : "";
|
|
103
|
+
if (!Number.isSafeInteger(scheduleId) || scheduleId <= 0 || !/^[a-f0-9]{32}$/u.test(terminalId)) {
|
|
104
|
+
throw new Error("安全重启卡片无效,请使用最新的状态卡片。");
|
|
105
|
+
}
|
|
106
|
+
if (scheduleId !== this.activeScheduleId || !this.options.isCurrentSchedule?.(scheduleId)
|
|
107
|
+
|| !this.lastStatus || ["cancelled", "restarting"].includes(this.lastStatus.phase)
|
|
108
|
+
|| !action.messageId || this.messageIds.get(action.contextKey) !== action.messageId
|
|
109
|
+
|| !this.cardTerminalIds.get(action.contextKey)?.has(terminalId)) {
|
|
110
|
+
throw new Error("该安全重启卡片或终端操作已失效,请查看最新状态卡片。");
|
|
111
|
+
}
|
|
112
|
+
if (!this.options.stopTerminal) throw new Error("网页终端未启用。");
|
|
113
|
+
const stopped = await this.options.stopTerminal(terminalId, ownerId);
|
|
114
|
+
return stopped ? "已请求结束对应终端,安全重启将继续等待其余阻塞项。" : "该终端已结束或正在关闭,无需重复操作。";
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
private publishPendingInitialStatus(): Promise<void> {
|
|
94
118
|
this.clearInitialCardTimer();
|
|
95
119
|
const status = this.pendingInitialStatus;
|
|
@@ -133,6 +157,8 @@ export class SafeRestartNotifier {
|
|
|
133
157
|
remainingMs: status.remainingMs,
|
|
134
158
|
pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
|
|
135
159
|
activeTerminals: status.activity.activeTerminals,
|
|
160
|
+
terminalBlockers: status.activity.terminalBlockers,
|
|
161
|
+
contextKey,
|
|
136
162
|
waitingTasks,
|
|
137
163
|
});
|
|
138
164
|
const cardHash = JSON.stringify(card);
|
|
@@ -141,12 +167,17 @@ export class SafeRestartNotifier {
|
|
|
141
167
|
if (this.cardHashes.get(contextKey) === cardHash) return;
|
|
142
168
|
try {
|
|
143
169
|
await this.outbound.updateInteractiveCard(messageId, card);
|
|
144
|
-
this.
|
|
170
|
+
if (this.activeScheduleId === status.scheduleId && this.messageIds.get(contextKey) === messageId) {
|
|
171
|
+
this.cardHashes.set(contextKey, cardHash);
|
|
172
|
+
this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
|
|
173
|
+
}
|
|
145
174
|
return;
|
|
146
175
|
} catch (error) {
|
|
147
176
|
this.logger.warn({ error, contextKey, messageId }, "Failed to update safe restart status card; sending a replacement.");
|
|
177
|
+
if (this.activeScheduleId !== status.scheduleId) return;
|
|
148
178
|
this.messageIds.delete(contextKey);
|
|
149
179
|
this.cardHashes.delete(contextKey);
|
|
180
|
+
this.cardTerminalIds.delete(contextKey);
|
|
150
181
|
}
|
|
151
182
|
}
|
|
152
183
|
try {
|
|
@@ -170,6 +201,7 @@ export class SafeRestartNotifier {
|
|
|
170
201
|
if (this.activeScheduleId === status.scheduleId) {
|
|
171
202
|
this.messageIds.set(contextKey, created);
|
|
172
203
|
this.cardHashes.set(contextKey, cardHash);
|
|
204
|
+
this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
|
|
173
205
|
} else {
|
|
174
206
|
await this.stopCard(created, status, reason ?? status.reason);
|
|
175
207
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { ActiveTerminal } from "../terminal/TerminalServer.js";
|
|
2
|
+
|
|
1
3
|
export interface ServerActivityState {
|
|
2
4
|
activeTerminals?: number;
|
|
5
|
+
terminalBlockers?: ActiveTerminal[];
|
|
3
6
|
runningSessions: number;
|
|
4
7
|
pendingFinalDeliveries: number;
|
|
5
8
|
latestInboundAt?: string;
|
|
@@ -66,6 +69,10 @@ export class SafeRestartScheduler {
|
|
|
66
69
|
return this.reason;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
isCurrentSchedule(scheduleId: number): boolean {
|
|
73
|
+
return this.scheduled && !this.restartingNow && this.scheduleId === scheduleId;
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
schedule(
|
|
70
77
|
reason: string,
|
|
71
78
|
notificationTarget?: RestartNotificationTarget,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface TerminalCommandState {
|
|
4
|
+
status: "unknown" | "idle" | "running";
|
|
5
|
+
command?: string;
|
|
6
|
+
source?: "shell" | "process";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class TerminalCommandTracker {
|
|
10
|
+
readonly nonce = randomBytes(16).toString("hex");
|
|
11
|
+
private readonly prefix = String.fromCharCode(27) + "]777;agentbot;" + this.nonce + ";";
|
|
12
|
+
private pending = "";
|
|
13
|
+
private current: TerminalCommandState = { status: "unknown" };
|
|
14
|
+
|
|
15
|
+
snapshot(): TerminalCommandState { return { ...this.current }; }
|
|
16
|
+
|
|
17
|
+
consume(data: string): string {
|
|
18
|
+
this.pending += data;
|
|
19
|
+
let output = "";
|
|
20
|
+
while (this.pending) {
|
|
21
|
+
const start = this.pending.indexOf(this.prefix);
|
|
22
|
+
if (start < 0) {
|
|
23
|
+
let retained = Math.min(this.prefix.length - 1, this.pending.length);
|
|
24
|
+
while (retained && !this.prefix.startsWith(this.pending.slice(-retained))) retained--;
|
|
25
|
+
output += this.pending.slice(0, this.pending.length - retained);
|
|
26
|
+
this.pending = this.pending.slice(this.pending.length - retained);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
output += this.pending.slice(0, start); this.pending = this.pending.slice(start);
|
|
30
|
+
const end = this.pending.indexOf("\u0007", this.prefix.length);
|
|
31
|
+
if (end < 0 && this.pending.length <= 32_768) break;
|
|
32
|
+
if (end < 0 || end > 32_768) {
|
|
33
|
+
output += this.pending[0]; this.pending = this.pending.slice(1); continue;
|
|
34
|
+
}
|
|
35
|
+
const payload = this.pending.slice(this.prefix.length, end);
|
|
36
|
+
if (payload === "idle") this.current = { status: "idle", source: "shell" };
|
|
37
|
+
else if (payload.startsWith("command;") && /^[A-Za-z0-9+/]*={0,2}$/u.test(payload.slice(8))) {
|
|
38
|
+
const command = Buffer.from(payload.slice(8), "base64").toString("utf8")
|
|
39
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "").trim();
|
|
40
|
+
this.current = { status: "running", source: "shell", command: command.slice(0, 2_000) || undefined };
|
|
41
|
+
} else this.current = { status: "unknown" };
|
|
42
|
+
this.pending = this.pending.slice(end + 1);
|
|
43
|
+
}
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function powerShellTrackingScript(nonce: string): string {
|
|
49
|
+
if (!/^[a-f0-9]{32}$/u.test(nonce)) throw new Error("Invalid terminal tracking nonce.");
|
|
50
|
+
// Wrap the accepted line, not the history hook (which skips duplicate commands).
|
|
51
|
+
// Leave editing/history policy intact and call the original prompt/readline before altering $?.
|
|
52
|
+
return [
|
|
53
|
+
"$global:__AgentBotPrompt = $function:prompt",
|
|
54
|
+
"$global:__AgentBotMarker = [char]27 + ']777;agentbot;" + nonce + ";'",
|
|
55
|
+
"try {",
|
|
56
|
+
" Import-Module PSReadLine -ErrorAction Stop",
|
|
57
|
+
" $global:__AgentBotReadLine = $function:PSConsoleHostReadLine",
|
|
58
|
+
" if ($null -eq $global:__AgentBotReadLine) { throw 'PSConsoleHostReadLine unavailable' }",
|
|
59
|
+
" function global:PSConsoleHostReadLine {",
|
|
60
|
+
" $line = & $global:__AgentBotReadLine",
|
|
61
|
+
" if (-not [string]::IsNullOrWhiteSpace($line)) {",
|
|
62
|
+
" $text = if ($line.Length -gt 2000) { $line.Substring(0, 2000) } else { $line }",
|
|
63
|
+
" [Console]::Write($global:__AgentBotMarker + 'command;' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($text)) + [char]7)",
|
|
64
|
+
" }",
|
|
65
|
+
" return $line",
|
|
66
|
+
" }",
|
|
67
|
+
" function global:prompt {",
|
|
68
|
+
" $text = & $global:__AgentBotPrompt",
|
|
69
|
+
" [Console]::Write($global:__AgentBotMarker + 'idle' + [char]7)",
|
|
70
|
+
" return $text",
|
|
71
|
+
" }",
|
|
72
|
+
"} catch { [Console]::Write($global:__AgentBotMarker + 'unknown' + [char]7) }",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
@@ -3,8 +3,11 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { IPty } from "node-pty";
|
|
6
|
+
import { powerShellTrackingScript, TerminalCommandTracker, type TerminalCommandState } from "./TerminalCommandTracker.js";
|
|
6
7
|
|
|
7
|
-
export type TerminalProcess = Pick<IPty, "write" | "resize" | "kill" | "onData" | "onExit"
|
|
8
|
+
export type TerminalProcess = Pick<IPty, "write" | "resize" | "kill" | "onData" | "onExit"> & {
|
|
9
|
+
commandState?(): TerminalCommandState;
|
|
10
|
+
};
|
|
8
11
|
export type TerminalSpawner = (cwd: string, cols: number, rows: number) => Promise<TerminalProcess>;
|
|
9
12
|
|
|
10
13
|
export function terminalShell(
|
|
@@ -63,9 +66,28 @@ export const spawnTerminal: TerminalSpawner = async (cwd, cols, rows) => {
|
|
|
63
66
|
prepareTerminalHelper(path.dirname(require.resolve("node-pty/package.json")));
|
|
64
67
|
}
|
|
65
68
|
const shell = terminalShell();
|
|
66
|
-
|
|
69
|
+
const tracker = new TerminalCommandTracker();
|
|
70
|
+
const powershell = /^(pwsh|powershell)(\.exe)?$/iu.test(path.basename(shell.file));
|
|
71
|
+
const args = powershell ? [...shell.args, "-NoExit", "-EncodedCommand",
|
|
72
|
+
Buffer.from(powerShellTrackingScript(tracker.nonce), "utf16le").toString("base64")] : shell.args;
|
|
73
|
+
const processPty = pty.spawn(shell.file, args, {
|
|
67
74
|
cwd, cols, rows, name: "xterm-256color", env: terminalEnvironment(), useConpty: true,
|
|
68
75
|
// The bundled ConPTY closes via its input pipe, avoiding node-pty's console-list helper on kill.
|
|
69
76
|
useConptyDll: process.platform === "win32",
|
|
70
77
|
});
|
|
78
|
+
return {
|
|
79
|
+
write: (data) => processPty.write(data), resize: (cols, rows) => processPty.resize(cols, rows), kill: () => processPty.kill(),
|
|
80
|
+
onExit: (listener) => processPty.onExit(listener),
|
|
81
|
+
onData: (listener) => processPty.onData((data) => { const visible = tracker.consume(data); if (visible) listener(visible); }),
|
|
82
|
+
commandState: () => {
|
|
83
|
+
const tracked = tracker.snapshot();
|
|
84
|
+
if (tracked.status !== "unknown" || process.platform === "win32") return tracked;
|
|
85
|
+
try {
|
|
86
|
+
const name = processPty.process;
|
|
87
|
+
// Unix PTYs can report a foreground process even without shell integration.
|
|
88
|
+
if (name && path.basename(name) !== path.basename(shell.file)) return { status: "running", command: name, source: "process" };
|
|
89
|
+
} catch { /* An exited PTY may no longer have a foreground process. */ }
|
|
90
|
+
return tracked;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
71
93
|
};
|
|
@@ -12,6 +12,7 @@ import type { CardAction } from "../feishu/types.js";
|
|
|
12
12
|
import { spawnTerminal, type TerminalProcess, type TerminalSpawner } from "./TerminalProcess.js";
|
|
13
13
|
import { terminalPage, TERMINAL_CSS, TERMINAL_SCRIPT } from "./TerminalPage.js";
|
|
14
14
|
import { TerminalLinks } from "./TerminalLinks.js";
|
|
15
|
+
import type { TerminalCommandState } from "./TerminalCommandTracker.js";
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
const AUTH_TTL = 5 * 60_000;
|
|
@@ -36,6 +37,11 @@ export interface TerminalCardView extends TerminalAuthorization {
|
|
|
36
37
|
terminalId: string; reopenToken: string; reopenUrl: string; recovery: boolean; state: "pending" | "approved" | "running" | "closed";
|
|
37
38
|
connected: boolean; message?: string;
|
|
38
39
|
}
|
|
40
|
+
export interface ActiveTerminal {
|
|
41
|
+
terminalId: string; token: string; cwd: string;
|
|
42
|
+
state: "starting" | "running" | "closing"; connected: boolean;
|
|
43
|
+
active: boolean; foreground: boolean; command: TerminalCommandState;
|
|
44
|
+
}
|
|
39
45
|
export interface TerminalServerOptions {
|
|
40
46
|
stateDirectory: string;
|
|
41
47
|
ownerId(): string | undefined;
|
|
@@ -133,6 +139,37 @@ export class TerminalServer {
|
|
|
133
139
|
return [...this.sessions.values()].filter((s) => s.pty || s.starting).length;
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
listActiveTerminals(): ActiveTerminal[] {
|
|
143
|
+
return [...this.sessions.values()].filter((s) => s.pty || s.starting).map((s) => {
|
|
144
|
+
const foreground = s.socket?.readyState === WebSocket.OPEN && (s.foregroundUntil ?? 0) > this.now();
|
|
145
|
+
let command: TerminalCommandState = { status: "unknown" };
|
|
146
|
+
if (s.state !== "closed") {
|
|
147
|
+
try { command = s.pty?.commandState?.() ?? command; }
|
|
148
|
+
catch { /* Command inspection must not prevent safe-restart activity checks. */ }
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
terminalId: s.requestId, token: s.reopenToken, cwd: s.cwd,
|
|
152
|
+
state: s.state === "closed" ? "closing" : s.pty ? "running" : "starting",
|
|
153
|
+
connected: s.socket?.readyState === WebSocket.OPEN,
|
|
154
|
+
active: s.state !== "closed" && (foreground || command.status === "running" || this.now() - s.lastActivity < 30_000),
|
|
155
|
+
foreground, command,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async stopActiveTerminal(terminalId: string, userId: string): Promise<boolean> {
|
|
161
|
+
const ownerId = this.options.ownerId();
|
|
162
|
+
if (!ownerId || userId !== ownerId) throw new Error("仅 AgentBot 所有者可结束终端。");
|
|
163
|
+
const session = this.sessions.get(terminalId);
|
|
164
|
+
if (!session || session.state === "closed" || (!session.pty && !session.starting)) return false;
|
|
165
|
+
if (session.ownerId !== userId) throw new Error("终端所有者已变更,请刷新状态。");
|
|
166
|
+
// Keep blocking restart if the OS refuses to terminate the Shell.
|
|
167
|
+
if (session.pty) session.pty.kill();
|
|
168
|
+
this.disposeSession(session, "所有者已从安全重启卡片结束终端", true);
|
|
169
|
+
await this.cardUpdates;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
136
173
|
async resolveApproval(action: CardAction): Promise<string> {
|
|
137
174
|
this.sweep();
|
|
138
175
|
const id = String(action.value.requestId ?? "");
|
|
@@ -425,11 +462,13 @@ export class TerminalServer {
|
|
|
425
462
|
this.assertCanStart();
|
|
426
463
|
const starting = (async () => {
|
|
427
464
|
const pty = await (this.options.spawn ?? spawnTerminal)(session.cwd, 100, 30);
|
|
465
|
+
session.pty = pty;
|
|
466
|
+
pty.onExit(({ exitCode }) => { session.pty = undefined; this.disposeSession(session, `Shell 已退出(${exitCode})`); });
|
|
428
467
|
if (this.closed || this.options.canStart?.() === false || session.state === "closed"
|
|
429
468
|
|| session.ownerId !== this.options.ownerId() || this.now() >= session.expiresAt) {
|
|
430
|
-
|
|
469
|
+
this.disposeSession(session, "服务正在重启或授权已失效", true); pty.kill(); return;
|
|
431
470
|
}
|
|
432
|
-
session.
|
|
471
|
+
session.state = "running"; session.lastActivity = this.now();
|
|
433
472
|
this.markDisconnected(session);
|
|
434
473
|
pty.onData((data) => {
|
|
435
474
|
if (session.state !== "running") return;
|
|
@@ -437,7 +476,6 @@ export class TerminalServer {
|
|
|
437
476
|
session.output = (session.output + data).slice(-256 * 1024);
|
|
438
477
|
if (session.socket) this.send(session.socket, { type: "data", data });
|
|
439
478
|
});
|
|
440
|
-
pty.onExit(({ exitCode }) => { session.pty = undefined; this.disposeSession(session, `Shell 已退出(${exitCode})`); });
|
|
441
479
|
this.options.logger.info({ requestId: session.requestId }, "Interactive terminal started.");
|
|
442
480
|
})();
|
|
443
481
|
session.starting = starting;
|
|
@@ -469,16 +507,16 @@ export class TerminalServer {
|
|
|
469
507
|
if (s.state === "running" && now - Math.max(s.lastActivity, s.foregroundUntil ?? 0) >= IDLE_TTL) {
|
|
470
508
|
this.disposeSession(s, "连续 30 分钟无输入或输出,Shell 已自动关闭");
|
|
471
509
|
}
|
|
472
|
-
if (s.state === "closed" && !s.starting && now - s.lastActivity >= AUTH_TTL) this.sessions.delete(s.requestId);
|
|
510
|
+
if (s.state === "closed" && !s.pty && !s.starting && now - s.lastActivity >= AUTH_TTL) this.sessions.delete(s.requestId);
|
|
473
511
|
}
|
|
474
512
|
}
|
|
475
|
-
private disposeSession(s: TerminalSession, message: string): void {
|
|
513
|
+
private disposeSession(s: TerminalSession, message: string, waitForExit = false): void {
|
|
476
514
|
if (s.state === "closed") return;
|
|
477
515
|
this.clearDisconnectTimer(s);
|
|
478
516
|
s.state = "closed"; s.message = message; s.lastActivity = this.now();
|
|
479
|
-
const pty = s.pty; s.pty = undefined;
|
|
517
|
+
const pty = s.pty; if (!waitForExit) s.pty = undefined;
|
|
480
518
|
if (s.socket) { this.send(s.socket, { type: "exit", message }); s.socket.close(); s.socket = undefined; }
|
|
481
|
-
try { pty?.kill(); } catch (error) { this.options.logger.warn({ error, requestId: s.requestId }, "Failed to close terminal process."); }
|
|
519
|
+
try { if (!waitForExit) pty?.kill(); } catch (error) { this.options.logger.warn({ error, requestId: s.requestId }, "Failed to close terminal process."); }
|
|
482
520
|
s.output = "";
|
|
483
521
|
for (const recovery of this.recoveries.values()) {
|
|
484
522
|
if (recovery.targetId === s.requestId) this.closeRecovery(recovery, message);
|