arona-agent 1.2.2 → 1.2.3
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/gui/main.cjs +29 -6
- package/gui/renderer/app.js +289 -39
- package/gui/renderer/index.html +6 -0
- package/gui/renderer/style.css +144 -2
- package/package.json +1 -1
- package/pet/agents.cjs +9 -4
- package/pet/main.cjs +41 -2
- package/pet/renderer/spinetest.js +3 -3
- package/pet/tools/gallery_capture.cjs +1 -1
- package/pet/tools/mouth_capture.cjs +1 -1
- package/pet/tools/visual_test.cjs +1 -1
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
- package/python/stt.py +24 -3
- package/src/agent.ts +11 -10
- package/src/agent_registry.ts +30 -0
- package/src/coding_agent.ts +6 -5
- package/src/commands.ts +56 -17
- package/src/config.ts +22 -0
- package/src/gui/controller.ts +385 -159
- package/src/gui/index.ts +26 -5
- package/src/gui/protocol.ts +9 -2
- package/src/gui/setup_backend.ts +15 -7
- package/src/index.ts +25 -5
- package/src/memory.ts +105 -12
- package/src/pet.ts +60 -1
- package/src/renderer.ts +27 -3
- package/src/repl.ts +24 -6
- package/src/setup.ts +14 -6
- package/src/speaker_context.ts +67 -1
- package/src/utils/python.ts +11 -6
- package/src/voice.ts +3 -2
- package/src/voices.ts +5 -1
- package/src/workspace.ts +163 -0
package/src/gui/controller.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// GuiController:Repl(src/repl.ts)的无 readline 版回合编排,GUI 模式专用。
|
|
2
2
|
// 与 Repl 的对应关系(双改须同步):parseInput / runRawTurn / runOneAgent / ensureSubSessions /
|
|
3
|
-
// waitTurnSettled / extractNewAssistantText / setActiveAgent /
|
|
4
|
-
//
|
|
3
|
+
// waitTurnSettled / extractNewAssistantText / setActiveAgent / resumeSession(渲染差异:输出走 agent_event 协议而非 stdout)。
|
|
4
|
+
// 会话管理为"槽位"模型:LLM 生成中切换会话/工作区不中断回合——旧会话挂后台继续,
|
|
5
|
+
// 结束按槽位存盘(含其定格的工作区),可随时从侧栏接回实时查看。
|
|
5
6
|
import { execSync } from "child_process";
|
|
7
|
+
import { homedir } from "os";
|
|
6
8
|
import { readFileSync, existsSync, writeFileSync } from "fs";
|
|
7
9
|
import { resolve, join } from "path";
|
|
8
10
|
import type { AgentSession, ModelRuntime, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { config } from "../config.ts";
|
|
11
|
+
import { config, getStoredWorkspaces, rememberWorkspace } from "../config.ts";
|
|
10
12
|
import * as memory from "../memory.ts";
|
|
11
13
|
import * as voice from "../voice.ts";
|
|
12
14
|
import { TtsStream } from "../tts_stream.ts";
|
|
@@ -26,24 +28,47 @@ import {
|
|
|
26
28
|
import * as skills from "../skills.ts";
|
|
27
29
|
import { SLASH_COMMANDS, resolveSlashCommand } from "../slash_registry.ts";
|
|
28
30
|
import { setCodingRunSink, setCodingEventSink } from "../coding_process.ts";
|
|
31
|
+
import { stripSpeakerPrefix, SpeakerPrefixStripper } from "../speaker_context.ts";
|
|
32
|
+
import { currentWorkspace, setActiveWorkspace, guiDefaultWorkspace, workspaceLabel } from "../workspace.ts";
|
|
29
33
|
import type { CodingRun } from "../memory.ts";
|
|
30
34
|
import type { GuiEvent, GuiState } from "./protocol.ts";
|
|
31
35
|
|
|
32
|
-
const PET_MAX_BUBBLE_LEN = 50; // 气泡字数上限(countTextUnits 口径):≥50
|
|
36
|
+
const PET_MAX_BUBBLE_LEN = 50; // 气泡字数上限(countTextUnits 口径):≥50 字的回复不上气泡
|
|
37
|
+
const MAX_BACKGROUND_SLOTS = 6; // 后台会话上限(超出时释放最早的未在生成的会话)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 会话槽位:每个 AgentSession 一份元数据。当前会话与"挂在后台继续生成"的会话统一管理——
|
|
41
|
+
* 切换会话不再复用/销毁同一个 session 对象,后台回合结束时按槽位存盘。
|
|
42
|
+
*/
|
|
43
|
+
interface SessionSlot {
|
|
44
|
+
session: AgentSession;
|
|
45
|
+
path: string | null; // 已落盘文件路径(null = 尚未存盘)
|
|
46
|
+
workspace: string; // 会话归属工作区(创建时定格;后台回合存盘用它,避免被切换后的 currentWorkspace 误标)
|
|
47
|
+
deleted: boolean; // 用户已删除该会话文件:不再回写
|
|
48
|
+
hasConversation: boolean; // 该会话是否发生过有效对话(原全局 hasConversation 的按会话版)
|
|
49
|
+
processing: boolean; // 回合进行中(可能在后台)
|
|
50
|
+
abortRequested: boolean; // 用户点了停止
|
|
51
|
+
pendingRuns: CodingRun[]; // 首次落盘前缓冲的子代理执行记录
|
|
52
|
+
undo: UndoManager; // 撤销快照按会话隔离(不同工作区/并发回合互不串扰)
|
|
53
|
+
subs: Map<SubAgentId, AgentSession>; // 群聊子 Agent 会话按主会话隔离(并发回合各自的子会话消息不互踩)
|
|
54
|
+
}
|
|
33
55
|
|
|
34
56
|
export class GuiController {
|
|
35
57
|
private session: AgentSession;
|
|
36
58
|
private modelRuntime: ModelRuntime;
|
|
37
59
|
private loader: DefaultResourceLoader;
|
|
38
|
-
private isProcessing = false;
|
|
39
|
-
private aborted = false;
|
|
40
60
|
private turnEnded = false;
|
|
41
61
|
private recording = false;
|
|
42
62
|
private sttAbort: AbortController | null = null;
|
|
43
63
|
private sttGraceful: AbortController | null = null;
|
|
44
|
-
// 当前会话文件被用户删除:置位后本轮对话不再回写文件(避免删除后被自动保存复活)
|
|
45
|
-
private currentSessionDeleted = false;
|
|
46
64
|
|
|
65
|
+
private slots = new Map<AgentSession, SessionSlot>();
|
|
66
|
+
// 进行中的回合栈(栈顶 = 最近开始的回合;编码子代理过程按它路由落盘)
|
|
67
|
+
private turnStack: SessionSlot[] = [];
|
|
68
|
+
private activeSession: AgentSession;
|
|
69
|
+
private activeAgentId: AgentId;
|
|
70
|
+
private bubbleHideTimer: NodeJS.Timeout | null = null;
|
|
71
|
+
private rendererUnsub: (() => void) | null = null;
|
|
47
72
|
private ttsStream = new TtsStream(
|
|
48
73
|
(agentId) => voice.isTtsEnabledFor(agentId),
|
|
49
74
|
() => {
|
|
@@ -55,19 +80,37 @@ export class GuiController {
|
|
|
55
80
|
},
|
|
56
81
|
);
|
|
57
82
|
|
|
58
|
-
private subSessions = new Map<SubAgentId, AgentSession>();
|
|
59
|
-
private activeSession: AgentSession;
|
|
60
|
-
private activeAgentId: AgentId;
|
|
61
|
-
private bubbleHideTimer: NodeJS.Timeout | null = null;
|
|
62
|
-
private rendererUnsub: (() => void) | null = null;
|
|
63
|
-
private currentSessionPath: string | null = null;
|
|
64
|
-
private undoManager: UndoManager;
|
|
65
|
-
// 尚未落盘的子代理执行记录(会话文件建立后一次性写入 sidecar)
|
|
66
|
-
private pendingCodingRuns: CodingRun[];
|
|
67
|
-
|
|
68
83
|
// 回合文本累积(与 renderer.ts 同构:只保留最后一个 assistant message 的文本)
|
|
69
84
|
private curMsgText = "";
|
|
70
85
|
private lastText = "";
|
|
86
|
+
// 流式剥离「名字:」前缀(模型偶发模仿历史消息写出;GUI 侧已单独标注说话人,会显示两遍)
|
|
87
|
+
private prefixStripper: SpeakerPrefixStripper | null = null;
|
|
88
|
+
|
|
89
|
+
/** 取会话槽位(无则按当前工作区创建)。 */
|
|
90
|
+
private slotOf(session: AgentSession): SessionSlot {
|
|
91
|
+
let slot = this.slots.get(session);
|
|
92
|
+
if (!slot) {
|
|
93
|
+
slot = {
|
|
94
|
+
session,
|
|
95
|
+
path: null,
|
|
96
|
+
workspace: currentWorkspace(),
|
|
97
|
+
deleted: false,
|
|
98
|
+
hasConversation: false,
|
|
99
|
+
processing: false,
|
|
100
|
+
abortRequested: false,
|
|
101
|
+
pendingRuns: [],
|
|
102
|
+
undo: new UndoManager(currentWorkspace()),
|
|
103
|
+
subs: new Map<SubAgentId, AgentSession>(),
|
|
104
|
+
};
|
|
105
|
+
slot.undo.load();
|
|
106
|
+
this.slots.set(session, slot);
|
|
107
|
+
}
|
|
108
|
+
return slot;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private get activeSlot(): SessionSlot {
|
|
112
|
+
return this.slotOf(this.activeSession);
|
|
113
|
+
}
|
|
71
114
|
|
|
72
115
|
constructor(
|
|
73
116
|
session: AgentSession,
|
|
@@ -75,7 +118,7 @@ export class GuiController {
|
|
|
75
118
|
loader: DefaultResourceLoader,
|
|
76
119
|
private emit: (ev: GuiEvent) => void,
|
|
77
120
|
private onExit: () => void,
|
|
78
|
-
private
|
|
121
|
+
private onCreateSession: () => Promise<{
|
|
79
122
|
session: AgentSession;
|
|
80
123
|
modelRuntime: ModelRuntime;
|
|
81
124
|
loader: DefaultResourceLoader;
|
|
@@ -87,22 +130,22 @@ export class GuiController {
|
|
|
87
130
|
this.activeSession = session;
|
|
88
131
|
this.activeAgentId = getMainAgent();
|
|
89
132
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// 编码子代理过程留痕:会话文件已存在 → 直接写 sidecar;
|
|
94
|
-
// 尚未落盘(新会话首回合)→ 缓冲,saveCurrentSessionIfNeeded 建文件后回填。
|
|
95
|
-
this.pendingCodingRuns = [];
|
|
133
|
+
// 编码子代理过程留痕:按栈顶回合的会话路由(该会话文件已存在 → 直接写 sidecar;
|
|
134
|
+
// 尚未落盘(新会话首回合)→ 缓冲,回合结束存盘后回填)。
|
|
96
135
|
setCodingRunSink((run) => {
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
|
|
136
|
+
const slot = this.turnStack[this.turnStack.length - 1] ?? this.activeSlot;
|
|
137
|
+
if (slot.deleted) return;
|
|
138
|
+
if (slot.path) {
|
|
139
|
+
memory.appendCodingRun(slot.path, run);
|
|
100
140
|
} else {
|
|
101
|
-
|
|
141
|
+
slot.pendingRuns.push(run);
|
|
102
142
|
}
|
|
103
143
|
});
|
|
104
|
-
// 编码子代理事件实时转发前端(agentId
|
|
144
|
+
// 编码子代理事件实时转发前端(agentId 区分角色,前端以对应角色名义渲染);
|
|
145
|
+
// 栈顶回合不是当前会话(后台回合)时不投影,避免后台过程串进前台画面。
|
|
105
146
|
setCodingEventSink((agentId, event) => {
|
|
147
|
+
const top = this.turnStack[this.turnStack.length - 1];
|
|
148
|
+
if (top && top.session !== this.activeSession) return;
|
|
106
149
|
this.emit({ type: "agent_event", agentId, event: event as Record<string, unknown> });
|
|
107
150
|
});
|
|
108
151
|
|
|
@@ -122,23 +165,67 @@ export class GuiController {
|
|
|
122
165
|
ttsEnabled: voice.isTtsEnabled(),
|
|
123
166
|
sttEnabled: voice.isSttEnabled(),
|
|
124
167
|
noVoice: config.noVoice,
|
|
125
|
-
processing: this.
|
|
168
|
+
processing: this.activeSlot.processing,
|
|
126
169
|
recording: this.recording,
|
|
127
|
-
currentSessionPath: this.
|
|
170
|
+
currentSessionPath: this.activeSlot.path,
|
|
128
171
|
};
|
|
129
172
|
}
|
|
130
173
|
|
|
131
|
-
/**
|
|
174
|
+
/** 推送侧栏会话列表(附当前会话路径供高亮 + 当前工作区与已知工作区)。 */
|
|
132
175
|
pushSessions(): void {
|
|
176
|
+
const listed = memory.listSessions();
|
|
177
|
+
// 已知工作区 = settings 选择历史 ∪ 会话 header 推导 ∪ 当前活动工作区(去重保序)
|
|
178
|
+
const known: string[] = [];
|
|
179
|
+
const push = (ws: string | null | undefined) => {
|
|
180
|
+
if (ws && !known.includes(ws)) known.push(ws);
|
|
181
|
+
};
|
|
182
|
+
for (const ws of getStoredWorkspaces()) push(ws);
|
|
183
|
+
for (const s of listed) push(s.workspace);
|
|
184
|
+
push(currentWorkspace()); // 启动时已无条件设定(上次选择或家目录),不在列表中丢失
|
|
133
185
|
this.emit({
|
|
134
186
|
type: "sessions",
|
|
135
|
-
currentPath: this.
|
|
136
|
-
|
|
137
|
-
|
|
187
|
+
currentPath: this.activeSlot.path,
|
|
188
|
+
currentWorkspace: currentWorkspace(),
|
|
189
|
+
homeDir: homedir(),
|
|
190
|
+
knownWorkspaces: known,
|
|
191
|
+
sessions: listed.map((s) => ({
|
|
192
|
+
path: s.path, preview: s.preview, timestamp: s.timestamp, model: s.model, workspace: s.workspace,
|
|
138
193
|
})),
|
|
139
194
|
});
|
|
140
195
|
}
|
|
141
196
|
|
|
197
|
+
/**
|
|
198
|
+
* 切换活动工作区(GUI 欢迎页选择器):当前会话存盘/转后台 → 切换 → 在新工作区建会话(SDK cwd 跟随)。
|
|
199
|
+
* 生成中也可切换:进行中的回合按旧会话槽位继续,结束按其定格的工作区存盘。
|
|
200
|
+
*/
|
|
201
|
+
async setWorkspace(path: string): Promise<void> {
|
|
202
|
+
const target = resolve(path);
|
|
203
|
+
if (target === currentWorkspace()) return;
|
|
204
|
+
if (!existsSync(target)) {
|
|
205
|
+
this.notice("error", t(`文件夹不存在:${target}`, `Folder not found: ${target}`));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await this.detachActive(); // 存盘/转后台须在切换前:存盘补写的 workspace 用旧值才正确
|
|
209
|
+
setActiveWorkspace(target);
|
|
210
|
+
rememberWorkspace(target);
|
|
211
|
+
await this.createAndAttach();
|
|
212
|
+
this.notice("success", t(
|
|
213
|
+
`工作区已切换:${workspaceLabel(target)}`,
|
|
214
|
+
`Workspace switched: ${workspaceLabel(target)}`,
|
|
215
|
+
));
|
|
216
|
+
this.emit({ type: "ready", state: this.buildState() });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 把会话移动到指定工作区(右键菜单),返回是否成功。 */
|
|
220
|
+
moveSessionByPath(path: string, workspace: string): void {
|
|
221
|
+
const ok = memory.setSessionWorkspace(path, workspace, true);
|
|
222
|
+
if (!ok) {
|
|
223
|
+
this.notice("error", t("移动失败。", "Move failed."));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.pushSessions();
|
|
227
|
+
}
|
|
228
|
+
|
|
142
229
|
private notice(level: "info" | "warn" | "error" | "success", text: string): void {
|
|
143
230
|
this.emit({ type: "notice", level, text });
|
|
144
231
|
}
|
|
@@ -166,46 +253,83 @@ export class GuiController {
|
|
|
166
253
|
|
|
167
254
|
private subscribeTo(session: AgentSession): () => void {
|
|
168
255
|
return session.subscribe((event: any) => {
|
|
256
|
+
// 前缀剥离把整个 delta 扣留时置位:本条事件不向下转发(无可上屏内容)
|
|
257
|
+
let dropEvent = false;
|
|
169
258
|
// GUI 渲染流:thinking/tool 按显示开关过滤;折叠规则由前端实现(尾部 3 行)
|
|
170
259
|
switch (event.type) {
|
|
171
260
|
case "message_start":
|
|
172
261
|
this.curMsgText = "";
|
|
262
|
+
this.prefixStripper = new SpeakerPrefixStripper(this.activeAgentId);
|
|
173
263
|
break;
|
|
174
264
|
case "message_update": {
|
|
175
265
|
const ae = event.assistantMessageEvent;
|
|
176
266
|
if (ae?.type === "text_delta") {
|
|
177
|
-
|
|
267
|
+
// 转发前剥离前缀:就地改写 delta,走末尾统一转发(前端上屏与 TTS/气泡累积都干净)
|
|
268
|
+
const stripped = this.prefixStripper?.push(ae.delta) ?? ae.delta;
|
|
269
|
+
if (!stripped) {
|
|
270
|
+
dropEvent = true; // 整段被扣留(前缀未判完)
|
|
271
|
+
} else {
|
|
272
|
+
this.curMsgText += stripped;
|
|
273
|
+
ae.delta = stripped;
|
|
274
|
+
}
|
|
178
275
|
}
|
|
179
276
|
break;
|
|
180
277
|
}
|
|
181
|
-
case "message_end":
|
|
278
|
+
case "message_end": {
|
|
279
|
+
// 放行剥离器仍扣留的内容(无前缀的短回复可能整段被扣到 message 结束)
|
|
280
|
+
const held = this.prefixStripper?.flush() ?? "";
|
|
281
|
+
if (held) {
|
|
282
|
+
this.curMsgText += held;
|
|
283
|
+
this.emit({
|
|
284
|
+
type: "agent_event",
|
|
285
|
+
agentId: this.activeAgentId,
|
|
286
|
+
event: {
|
|
287
|
+
type: "message_update",
|
|
288
|
+
assistantMessageEvent: { type: "text_delta", delta: held },
|
|
289
|
+
} as Record<string, unknown>,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
182
292
|
this.lastText = this.curMsgText.trim();
|
|
183
293
|
this.curMsgText = "";
|
|
184
294
|
break;
|
|
295
|
+
}
|
|
185
296
|
case "agent_end": {
|
|
186
|
-
// TTS 收尾 +
|
|
187
|
-
if (this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
297
|
+
// TTS 收尾 + 桌宠气泡:仅前台会话的回合(后台会话用户看不到,静默继续避免抢音/抢气泡)
|
|
298
|
+
if (session === this.activeSession) {
|
|
299
|
+
if (this.lastText) {
|
|
300
|
+
this.ttsStream.endTurn(this.lastText);
|
|
301
|
+
if (pet.isRunning) {
|
|
302
|
+
const units = countTextUnits(this.lastText);
|
|
303
|
+
if (units > 0 && units < PET_MAX_BUBBLE_LEN) {
|
|
304
|
+
pet.sendText(this.activeAgentId, "final", this.lastText);
|
|
305
|
+
}
|
|
193
306
|
}
|
|
194
307
|
}
|
|
308
|
+
this.lastText = "";
|
|
195
309
|
}
|
|
196
|
-
this.lastText = "";
|
|
197
310
|
break;
|
|
198
311
|
}
|
|
199
312
|
}
|
|
313
|
+
if (dropEvent) return;
|
|
200
314
|
// 协议转发(思考块与工具详情始终显示,前端自行分流渲染)
|
|
201
315
|
this.emit({ type: "agent_event", agentId: this.activeAgentId, event: event as Record<string, unknown> });
|
|
202
316
|
});
|
|
203
317
|
}
|
|
204
318
|
|
|
205
|
-
/**
|
|
319
|
+
/** 该会话是否是前台主会话的群聊子会话。 */
|
|
320
|
+
private isFrontSub(session: AgentSession): boolean {
|
|
321
|
+
for (const sub of this.activeSlot.subs.values()) {
|
|
322
|
+
if (sub === session) return true;
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 切 renderer 订阅到指定角色 session(与 Repl.setActiveAgent 一致)。
|
|
328
|
+
* 仅前台会话(及其群聊子会话)的回合接管渲染;后台会话的回合静默继续,
|
|
329
|
+
* 结束后由 finally 存盘,不抢订阅。 */
|
|
206
330
|
private setActiveAgent(agentId: AgentId, session: AgentSession): void {
|
|
331
|
+
if (session !== this.activeSession && !this.isFrontSub(session)) return;
|
|
207
332
|
this.activeAgentId = agentId;
|
|
208
|
-
this.activeSession = session;
|
|
209
333
|
this.curMsgText = "";
|
|
210
334
|
this.lastText = "";
|
|
211
335
|
this.rendererUnsub?.();
|
|
@@ -334,13 +458,13 @@ export class GuiController {
|
|
|
334
458
|
return;
|
|
335
459
|
|
|
336
460
|
case "undo": {
|
|
337
|
-
const r = await this.
|
|
461
|
+
const r = await this.activeSlot.undo.undo();
|
|
338
462
|
this.notice(r.ok ? "success" : "warn", r.message);
|
|
339
463
|
return;
|
|
340
464
|
}
|
|
341
465
|
|
|
342
466
|
case "redo": {
|
|
343
|
-
const r = await this.
|
|
467
|
+
const r = await this.activeSlot.undo.redo();
|
|
344
468
|
this.notice(r.ok ? "success" : "warn", r.message);
|
|
345
469
|
return;
|
|
346
470
|
}
|
|
@@ -470,10 +594,9 @@ export class GuiController {
|
|
|
470
594
|
setMainAgent(main as MainAgentId);
|
|
471
595
|
setSubAgents(validSubs);
|
|
472
596
|
pet.restartWithSelection(main as AgentId, validSubs);
|
|
473
|
-
if (subsChanged) this.resetSubSessions();
|
|
474
597
|
if (mainChanged) {
|
|
475
598
|
this.ttsStream.restartVoice();
|
|
476
|
-
|
|
599
|
+
// 保存/转后台由 newSession 的 detachActive 统一处理
|
|
477
600
|
await this.newSession();
|
|
478
601
|
this.notice("success", t(
|
|
479
602
|
`主 Agent 已切换为 ${getAgentLabel(main as MainAgentId)}。`,
|
|
@@ -486,112 +609,179 @@ export class GuiController {
|
|
|
486
609
|
}
|
|
487
610
|
|
|
488
611
|
// ============================================================
|
|
489
|
-
//
|
|
612
|
+
// 会话(槽位模型:当前会话与后台会话统一管理)
|
|
490
613
|
// ============================================================
|
|
491
614
|
|
|
615
|
+
/** 新会话:当前会话若在生成则转后台继续,否则存盘释放;然后建新会话。 */
|
|
492
616
|
private async newSession(): Promise<void> {
|
|
493
|
-
this.
|
|
494
|
-
|
|
617
|
+
await this.detachActive();
|
|
618
|
+
await this.createAndAttach();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 创建新 AgentSession(cwd 跟随当前活动工作区)并接管前台。 */
|
|
622
|
+
private async createAndAttach(): Promise<void> {
|
|
623
|
+
const result = await this.onCreateSession();
|
|
495
624
|
this.session = result.session;
|
|
496
625
|
this.modelRuntime = result.modelRuntime;
|
|
497
626
|
this.loader = result.loader;
|
|
498
|
-
this.
|
|
627
|
+
this.attach(result.session);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 接管某会话为前台:绑定渲染订阅并下发回放数据(重新接上后台会话时含其当前消息)。 */
|
|
631
|
+
private attach(session: AgentSession, replay?: { runs: CodingRun[]; messages: unknown[] }): void {
|
|
632
|
+
this.activeSession = session;
|
|
499
633
|
this.activeAgentId = getMainAgent();
|
|
500
|
-
this.
|
|
501
|
-
this.
|
|
502
|
-
this.
|
|
503
|
-
this.resetSubSessions();
|
|
634
|
+
this.curMsgText = "";
|
|
635
|
+
this.lastText = "";
|
|
636
|
+
this.prefixStripper = null;
|
|
504
637
|
this.rendererUnsub?.();
|
|
505
|
-
this.rendererUnsub = this.subscribeTo(
|
|
506
|
-
|
|
638
|
+
this.rendererUnsub = this.subscribeTo(session);
|
|
639
|
+
if (replay) {
|
|
640
|
+
this.emit({ type: "coding_runs", runs: replay.runs });
|
|
641
|
+
this.emit({ type: "history", messages: replay.messages });
|
|
642
|
+
} else {
|
|
643
|
+
const slot = this.slotOf(session);
|
|
644
|
+
// 重新接上(可能是后台生成中的会话):下发已有消息,之后实时事件继续流向前台
|
|
645
|
+
this.emit({ type: "coding_runs", runs: slot.path ? memory.loadCodingRuns(slot.path) : [] });
|
|
646
|
+
this.emit({ type: "history", messages: (session.agent.state.messages ?? []) as unknown[] });
|
|
647
|
+
}
|
|
648
|
+
this.emit({ type: "ready", state: this.buildState() });
|
|
507
649
|
this.pushSessions();
|
|
508
650
|
}
|
|
509
651
|
|
|
510
|
-
/**
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
652
|
+
/**
|
|
653
|
+
* 脱离当前前台会话:
|
|
654
|
+
* - 回合进行中 → 先按当前内容落盘(修剪残缺尾部,侧栏立即可见、随时可点回),
|
|
655
|
+
* 再挂后台:断开渲染订阅,回合继续,结束时以完整上下文覆盖存盘;
|
|
656
|
+
* - 空闲 → 立即存盘并释放(含其子会话)。
|
|
657
|
+
*/
|
|
658
|
+
private detachActive(): void {
|
|
659
|
+
const old = this.activeSession;
|
|
660
|
+
const slot = this.slotOf(old);
|
|
661
|
+
if (slot.processing) {
|
|
662
|
+
this.saveSlot(slot, old); // 立即落盘(修剪版):侧栏马上出现该会话,点击可接回实时画面
|
|
663
|
+
this.rendererUnsub?.();
|
|
664
|
+
this.rendererUnsub = null;
|
|
665
|
+
this.pruneBackgroundSlots(old);
|
|
666
|
+
} else {
|
|
667
|
+
this.saveSlot(slot, old);
|
|
668
|
+
this.disposeSession(old);
|
|
516
669
|
}
|
|
517
|
-
this.pushSessions();
|
|
518
670
|
}
|
|
519
671
|
|
|
520
|
-
/**
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
if (
|
|
524
|
-
|
|
525
|
-
|
|
672
|
+
/** 后台会话超上限时释放最早的未在生成的会话(渲染订阅已断开,直接释放即可)。 */
|
|
673
|
+
private pruneBackgroundSlots(keep: AgentSession): void {
|
|
674
|
+
const bg = [...this.slots.values()].filter((s) => s.session !== keep);
|
|
675
|
+
if (bg.length <= MAX_BACKGROUND_SLOTS) return;
|
|
676
|
+
for (const slot of bg) {
|
|
677
|
+
if (this.slots.size <= MAX_BACKGROUND_SLOTS + 1) break; // +1 为前台会话
|
|
678
|
+
if (!slot.processing) this.disposeSession(slot.session);
|
|
526
679
|
}
|
|
527
|
-
if (this.currentSessionPath === path) this.currentSessionPath = newPath;
|
|
528
|
-
this.pushSessions();
|
|
529
|
-
return true;
|
|
530
680
|
}
|
|
531
681
|
|
|
532
|
-
|
|
533
|
-
this.
|
|
682
|
+
private disposeSession(session: AgentSession): void {
|
|
683
|
+
const slot = this.slots.get(session);
|
|
684
|
+
if (slot) {
|
|
685
|
+
for (const sub of slot.subs.values()) {
|
|
686
|
+
try {
|
|
687
|
+
sub.dispose();
|
|
688
|
+
} catch {
|
|
689
|
+
// 回收失败不影响主流程
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
534
693
|
try {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
memory.resetConversationFlag();
|
|
539
|
-
this.currentSessionPath = path;
|
|
540
|
-
this.currentSessionDeleted = false;
|
|
541
|
-
// 先下发子代理过程(前端按 toolCallId 建关联),再下发历史触发回放渲染
|
|
542
|
-
this.emit({ type: "coding_runs", runs: memory.loadCodingRuns(path) });
|
|
543
|
-
this.emit({ type: "history", messages });
|
|
544
|
-
this.pushSessions();
|
|
545
|
-
} catch (err) {
|
|
546
|
-
this.notice("error", `Failed to load session: ${err instanceof Error ? err.message : err}`);
|
|
694
|
+
session.dispose();
|
|
695
|
+
} catch {
|
|
696
|
+
// 回收失败不影响主流程
|
|
547
697
|
}
|
|
698
|
+
this.slots.delete(session);
|
|
548
699
|
}
|
|
549
700
|
|
|
550
701
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
* -
|
|
554
|
-
*
|
|
702
|
+
* 按槽位存盘。silent=true(默认)用于自动保存;前台会话首次落盘时弹「已保存」提示。
|
|
703
|
+
* - slot.path 非 null:覆盖原文件(workspace 保留 header 原值,缺失时补写槽位定格的工作区)
|
|
704
|
+
* - 为 null:仅有有效对话时另存为新文件(归属槽位定格的工作区,后台回合不被切换误标)
|
|
705
|
+
* 生成中的会话由 memory 写盘函数统一修剪残缺尾部(防 resume 400),不影响内存与后台回合。
|
|
555
706
|
*/
|
|
556
|
-
private
|
|
557
|
-
if (
|
|
558
|
-
const messages =
|
|
559
|
-
const model =
|
|
560
|
-
if (
|
|
561
|
-
memory.saveSessionToPath(
|
|
562
|
-
} else if (
|
|
563
|
-
|
|
564
|
-
if (
|
|
707
|
+
private saveSlot(slot: SessionSlot, session: AgentSession, silent = true): void {
|
|
708
|
+
if (slot.deleted) return; // 用户已删除该会话,不再回写
|
|
709
|
+
const messages = session.agent.state.messages as any[];
|
|
710
|
+
const model = session.model?.id || "unknown";
|
|
711
|
+
if (slot.path) {
|
|
712
|
+
memory.saveSessionToPath(slot.path, messages, model, silent, slot.workspace);
|
|
713
|
+
} else if (slot.hasConversation) {
|
|
714
|
+
slot.path = memory.saveSession(messages, model, silent, slot.workspace);
|
|
715
|
+
if (slot.path && !silent && session === this.activeSession) {
|
|
565
716
|
this.notice("success", t(`会话已保存`, "Session saved"));
|
|
566
717
|
}
|
|
567
718
|
}
|
|
568
719
|
// 首次落盘后,回填期间缓冲的子代理执行记录
|
|
569
|
-
if (
|
|
570
|
-
for (const run of
|
|
571
|
-
memory.appendCodingRun(
|
|
720
|
+
if (slot.path && slot.pendingRuns.length) {
|
|
721
|
+
for (const run of slot.pendingRuns) {
|
|
722
|
+
memory.appendCodingRun(slot.path, run);
|
|
572
723
|
}
|
|
573
|
-
|
|
724
|
+
slot.pendingRuns = [];
|
|
574
725
|
}
|
|
575
726
|
}
|
|
576
727
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
728
|
+
/** 恢复会话:后台已有该会话(可能仍在生成)直接接回;否则新建会话加载历史。 */
|
|
729
|
+
async resumeSession(path: string): Promise<void> {
|
|
730
|
+
if (this.activeSlot.path === path) return;
|
|
731
|
+
for (const slot of this.slots.values()) {
|
|
732
|
+
if (slot.session !== this.activeSession && slot.path === path) {
|
|
733
|
+
this.detachActive();
|
|
734
|
+
this.attach(slot.session); // 接回:生成中则继续实时渲染
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
this.detachActive();
|
|
739
|
+
try {
|
|
740
|
+
const messages = memory.loadSession(path);
|
|
741
|
+
const { session } = await this.onCreateSession();
|
|
742
|
+
session.agent.state.messages = messages;
|
|
743
|
+
this.session = session;
|
|
744
|
+
const slot = this.slotOf(session);
|
|
745
|
+
slot.path = path;
|
|
746
|
+
this.attach(session, { runs: memory.loadCodingRuns(path), messages });
|
|
747
|
+
} catch (err) {
|
|
748
|
+
this.notice("error", `Failed to load session: ${err instanceof Error ? err.message : err}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** 删除会话文件;命中所有槽位(含后台生成中的)标记删除,后续不再回写。 */
|
|
753
|
+
deleteSessionByPath(path: string): void {
|
|
754
|
+
memory.deleteSession(path);
|
|
755
|
+
for (const slot of this.slots.values()) {
|
|
756
|
+
if (slot.path === path) {
|
|
757
|
+
slot.deleted = true;
|
|
758
|
+
slot.path = null;
|
|
583
759
|
}
|
|
584
760
|
}
|
|
585
|
-
this.
|
|
761
|
+
this.pushSessions();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** 重命名会话(更新 preview 与文件名),返回是否成功;槽位里的路径同步跟随。 */
|
|
765
|
+
renameSessionByPath(path: string, title: string): boolean {
|
|
766
|
+
const newPath = memory.renameSession(path, title);
|
|
767
|
+
if (!newPath) {
|
|
768
|
+
this.notice("error", t("重命名失败。", "Rename failed."));
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
for (const slot of this.slots.values()) {
|
|
772
|
+
if (slot.path === path) slot.path = newPath;
|
|
773
|
+
}
|
|
774
|
+
this.pushSessions();
|
|
775
|
+
return true;
|
|
586
776
|
}
|
|
587
777
|
|
|
588
|
-
private async ensureSubSessions(): Promise<void> {
|
|
589
|
-
const
|
|
590
|
-
for (const id of
|
|
591
|
-
if (
|
|
778
|
+
private async ensureSubSessions(main: AgentSession): Promise<void> {
|
|
779
|
+
const slot = this.slotOf(main);
|
|
780
|
+
for (const id of getSubAgents()) {
|
|
781
|
+
if (slot.subs.has(id)) continue;
|
|
592
782
|
try {
|
|
593
783
|
const { session } = await initSubAgent(id, this.modelRuntime);
|
|
594
|
-
|
|
784
|
+
slot.subs.set(id, session);
|
|
595
785
|
} catch (err) {
|
|
596
786
|
this.notice("error", t(
|
|
597
787
|
`初始化子 Agent ${getAgentLabel(id)} 失败:${err instanceof Error ? err.message : err},已跳过该角色。`,
|
|
@@ -655,17 +845,20 @@ export class GuiController {
|
|
|
655
845
|
}
|
|
656
846
|
|
|
657
847
|
private async runOneAgent(
|
|
848
|
+
mainSession: AgentSession,
|
|
658
849
|
session: AgentSession,
|
|
659
850
|
agentId: AgentId,
|
|
660
851
|
input: string,
|
|
661
852
|
isSub: boolean,
|
|
662
853
|
): Promise<string> {
|
|
663
854
|
this.setActiveAgent(agentId, session);
|
|
664
|
-
this.
|
|
855
|
+
if (session === this.activeSession || this.isFrontSub(session)) {
|
|
856
|
+
this.ttsStream.setVoice(agentId);
|
|
857
|
+
}
|
|
665
858
|
|
|
666
859
|
if (isSub) {
|
|
667
|
-
// 子 Agent
|
|
668
|
-
session.agent.state.messages = [...(
|
|
860
|
+
// 子 Agent:复制所属主 session 全量群聊日志作为上下文(浅拷贝,元素引用共享)
|
|
861
|
+
session.agent.state.messages = [...(mainSession.agent.state.messages as any[])];
|
|
669
862
|
}
|
|
670
863
|
|
|
671
864
|
const stateMessages = session.agent.state.messages as any[];
|
|
@@ -673,8 +866,8 @@ export class GuiController {
|
|
|
673
866
|
|
|
674
867
|
const promptText = isSub
|
|
675
868
|
? t(
|
|
676
|
-
`(你是${getAgentLabel(agentId)}
|
|
677
|
-
`(You are ${getAgentLabel(agentId)}. It's your turn — stay in your own character and voice; do not play or mimic another character. Speak briefly.)`,
|
|
869
|
+
`(你是${getAgentLabel(agentId)}。现在轮到你发言——保持你自己的身份和语气,不要扮演或模仿其他角色。直接说台词,不要以「${getAgentLabel(agentId)}:」这类名字前缀开头。请简短发言。)`,
|
|
870
|
+
`(You are ${getAgentLabel(agentId)}. It's your turn — stay in your own character and voice; do not play or mimic another character. Speak your line directly, without starting with a name prefix like "${getAgentLabel(agentId)}:". Speak briefly.)`,
|
|
678
871
|
)
|
|
679
872
|
: input;
|
|
680
873
|
|
|
@@ -685,6 +878,17 @@ export class GuiController {
|
|
|
685
878
|
return "";
|
|
686
879
|
}
|
|
687
880
|
|
|
881
|
+
// 输出侧兜底:模型偶发模仿历史消息把「星野:」这类前缀写进台词,统一剥掉
|
|
882
|
+
// (与 Repl.runOneAgent 同步;否则下轮 speaker 扩展会叠出双重前缀)
|
|
883
|
+
for (const m of stateMessages.slice(startLen)) {
|
|
884
|
+
if (m.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
885
|
+
for (const b of m.content) {
|
|
886
|
+
if (b.type === "text" && typeof b.text === "string" && b.text) {
|
|
887
|
+
b.text = stripSpeakerPrefix(b.text, agentId);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
688
892
|
const text = this.extractNewAssistantText(stateMessages, startLen);
|
|
689
893
|
if (!text) return "";
|
|
690
894
|
|
|
@@ -693,7 +897,7 @@ export class GuiController {
|
|
|
693
897
|
if (m.role === "assistant") m.speaker = agentId;
|
|
694
898
|
}
|
|
695
899
|
} else {
|
|
696
|
-
(
|
|
900
|
+
(mainSession.agent.state.messages as any[]).push({
|
|
697
901
|
role: "assistant",
|
|
698
902
|
speaker: agentId,
|
|
699
903
|
content: [{ type: "text", text }],
|
|
@@ -703,55 +907,62 @@ export class GuiController {
|
|
|
703
907
|
}
|
|
704
908
|
|
|
705
909
|
async runRawTurn(input: string): Promise<void> {
|
|
910
|
+
// 回合上下文整体局部化到槽位:回合进行中用户可切换会话/工作区,
|
|
911
|
+
// 本回合在后台继续,结束时按槽位存盘(不再读写"当前会话"的易变状态)
|
|
912
|
+
const turnSession = this.activeSession;
|
|
913
|
+
const turnSlot = this.slotOf(turnSession);
|
|
914
|
+
turnSlot.hasConversation = true;
|
|
915
|
+
turnSlot.abortRequested = false;
|
|
916
|
+
turnSlot.processing = true;
|
|
917
|
+
this.turnStack.push(turnSlot);
|
|
706
918
|
memory.markConversation();
|
|
707
|
-
this.isProcessing = true;
|
|
708
|
-
this.turnEnded = false;
|
|
709
919
|
this.ttsStream.cancel();
|
|
710
920
|
this.hidePetBubble();
|
|
711
|
-
|
|
921
|
+
this.emit({ type: "ready", state: this.buildState() });
|
|
922
|
+
await turnSlot.undo.beforeTurn();
|
|
712
923
|
|
|
713
924
|
try {
|
|
714
|
-
await this.ensureSubSessions();
|
|
925
|
+
await this.ensureSubSessions(turnSession);
|
|
715
926
|
|
|
716
927
|
// 记忆增量检测:MEMORY.md 运行时变更 → 追加到下一轮主 Agent 的 user 消息末尾
|
|
717
928
|
const memoryDelta = memory.getMemoryDelta();
|
|
718
929
|
const mainInput = memoryDelta ? `${input}\n\n${memoryDelta}` : input;
|
|
719
930
|
|
|
720
|
-
await this.runOneAgent(
|
|
721
|
-
if (
|
|
931
|
+
await this.runOneAgent(turnSession, turnSession, getMainAgent(), mainInput, false);
|
|
932
|
+
if (turnSlot.abortRequested) return;
|
|
722
933
|
await this.waitTurnSettled(getMainAgent());
|
|
723
|
-
if (
|
|
934
|
+
if (turnSlot.abortRequested) return;
|
|
724
935
|
|
|
936
|
+
const subs = turnSlot.subs;
|
|
725
937
|
for (const subId of getSubAgents()) {
|
|
726
|
-
const subSession =
|
|
938
|
+
const subSession = subs.get(subId);
|
|
727
939
|
if (!subSession) continue;
|
|
728
|
-
await this.runOneAgent(subSession, subId, input, true);
|
|
729
|
-
if (
|
|
940
|
+
await this.runOneAgent(turnSession, subSession, subId, input, true);
|
|
941
|
+
if (turnSlot.abortRequested) return;
|
|
730
942
|
await this.waitTurnSettled(subId);
|
|
731
|
-
if (
|
|
943
|
+
if (turnSlot.abortRequested) return;
|
|
732
944
|
}
|
|
733
945
|
|
|
734
|
-
this.setActiveAgent(getMainAgent(),
|
|
946
|
+
this.setActiveAgent(getMainAgent(), turnSession);
|
|
735
947
|
} catch (err) {
|
|
736
948
|
this.notice("error", t("错误:", "Error: ") + (err instanceof Error ? err.message : err));
|
|
737
949
|
} finally {
|
|
738
950
|
try {
|
|
739
|
-
await
|
|
951
|
+
await turnSlot.undo.afterTurn();
|
|
740
952
|
} catch (err) {
|
|
741
953
|
this.notice("warn", t(`撤销快照记录失败:${err instanceof Error ? err.message : err}`, `Failed to record undo snapshot: ${err instanceof Error ? err.message : err}`));
|
|
742
954
|
}
|
|
743
|
-
|
|
955
|
+
turnSlot.processing = false;
|
|
744
956
|
this.turnEnded = true;
|
|
745
|
-
|
|
746
|
-
|
|
957
|
+
this.turnStack = this.turnStack.filter((s) => s !== turnSlot);
|
|
958
|
+
// 每回合结束自动存盘(静默;resume 会话覆盖原文件)——前台后台一视同仁,并刷新侧栏
|
|
959
|
+
this.saveSlot(turnSlot, turnSession);
|
|
747
960
|
this.pushSessions();
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
this.aborted = false;
|
|
754
|
-
return;
|
|
961
|
+
if (turnSession === this.activeSession) {
|
|
962
|
+
if (!this.ttsStream.isPending) {
|
|
963
|
+
pet.reset();
|
|
964
|
+
this.scheduleBubbleHide();
|
|
965
|
+
}
|
|
755
966
|
}
|
|
756
967
|
this.emit({ type: "ready", state: this.buildState() });
|
|
757
968
|
}
|
|
@@ -762,10 +973,11 @@ export class GuiController {
|
|
|
762
973
|
// ============================================================
|
|
763
974
|
|
|
764
975
|
abort(): void {
|
|
765
|
-
|
|
976
|
+
const slot = this.activeSlot;
|
|
977
|
+
if (!slot.processing) return;
|
|
766
978
|
this.activeSession.abort().catch(() => {});
|
|
767
|
-
|
|
768
|
-
|
|
979
|
+
slot.abortRequested = true;
|
|
980
|
+
slot.processing = false; // UI 立即恢复;runRawTurn 的 finally 兜底收尾
|
|
769
981
|
this.ttsStream.cancel();
|
|
770
982
|
this.hidePetBubble();
|
|
771
983
|
this.notice("info", "[aborted]");
|
|
@@ -779,7 +991,7 @@ export class GuiController {
|
|
|
779
991
|
this.notice("info", t("STT 已关闭(用 /stt 打开)。", "STT is off (use /stt to enable)."));
|
|
780
992
|
return;
|
|
781
993
|
}
|
|
782
|
-
if (this.
|
|
994
|
+
if (this.activeSlot.processing) {
|
|
783
995
|
// 任务中录音:先中断当前任务(对齐 CLI triggerStt 行为)
|
|
784
996
|
this.abort();
|
|
785
997
|
}
|
|
@@ -805,7 +1017,15 @@ export class GuiController {
|
|
|
805
1017
|
}
|
|
806
1018
|
|
|
807
1019
|
async doExit(): Promise<void> {
|
|
808
|
-
|
|
1020
|
+
// 所有会话槽位落盘(前台保留「已保存」提示;后台/生成中的会话按各自工作区静默保存)
|
|
1021
|
+
const active = this.activeSlot;
|
|
1022
|
+
const activeHadPath = active.path !== null;
|
|
1023
|
+
for (const slot of this.slots.values()) {
|
|
1024
|
+
this.saveSlot(slot, slot.session, slot !== active);
|
|
1025
|
+
}
|
|
1026
|
+
if (!activeHadPath && active.path && active.hasConversation) {
|
|
1027
|
+
this.notice("success", t(`会话已保存`, "Session saved"));
|
|
1028
|
+
}
|
|
809
1029
|
this.ttsStream.shutdown();
|
|
810
1030
|
stopGptSovitsLocalServer();
|
|
811
1031
|
stopComputerUse();
|
|
@@ -815,12 +1035,18 @@ export class GuiController {
|
|
|
815
1035
|
// 清理失败不影响退出
|
|
816
1036
|
}
|
|
817
1037
|
stopPet();
|
|
818
|
-
this.
|
|
819
|
-
for (const subSession of this.subSessions.values()) {
|
|
1038
|
+
for (const slot of this.slots.values()) {
|
|
820
1039
|
try {
|
|
821
|
-
|
|
1040
|
+
slot.session.dispose();
|
|
822
1041
|
} catch {
|
|
823
|
-
//
|
|
1042
|
+
// 清理失败不影响退出
|
|
1043
|
+
}
|
|
1044
|
+
for (const subSession of slot.subs.values()) {
|
|
1045
|
+
try {
|
|
1046
|
+
subSession.dispose();
|
|
1047
|
+
} catch {
|
|
1048
|
+
// 子 session 清理失败不影响退出
|
|
1049
|
+
}
|
|
824
1050
|
}
|
|
825
1051
|
}
|
|
826
1052
|
this.emit({ type: "exiting" });
|