aicodeswitch 5.2.12 → 5.2.13
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/README.md +6 -2
- package/UPGRADE.md +5 -4
- package/bin/utils/get-server.js +3 -3
- package/dist/server/access-keys/index.js +2 -2
- package/dist/server/access-keys/key-logger.js +63 -309
- package/dist/server/agent-map/activity-extractor.js +383 -0
- package/dist/server/agent-map/agent-map-service.js +707 -0
- package/dist/server/agent-map/index.js +10 -0
- package/dist/server/agent-map/routes.js +88 -0
- package/dist/server/agent-map/session-meta.js +300 -0
- package/dist/server/fs-database.js +163 -926
- package/dist/server/log-store/index.js +21 -0
- package/dist/server/log-store/log-store.js +1367 -0
- package/dist/server/log-store/types.js +2 -0
- package/dist/server/main.js +136 -18
- package/dist/server/notifier.js +100 -0
- package/dist/server/proxy-server.js +73 -34
- package/dist/ui/assets/index-4liE4bV8.css +1 -0
- package/dist/ui/assets/index-CJMAVkIN.js +813 -0
- package/dist/ui/index.html +2 -2
- package/package.json +2 -2
- package/scripts/dev.js +237 -0
- package/dist/ui/assets/index-BFVjD9Y2.js +0 -799
- package/dist/ui/assets/index-Dm34-4zP.css +0 -1
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.agentMapService = exports.AgentMapService = void 0;
|
|
13
|
+
/**
|
|
14
|
+
* Agent Map 服务(单例)
|
|
15
|
+
*
|
|
16
|
+
* 职责:
|
|
17
|
+
* 1. 维护在途请求注册表(sessionId → 在途计数),用于 active 判定
|
|
18
|
+
* 2. 维护每个 Session 的运行时聚合态(SessionMapItem)—— 状态由活跃度自动推断
|
|
19
|
+
* 3. 维护活动事件环形缓冲(每 session + 全局)
|
|
20
|
+
* 4. 通过 EventEmitter 向 SSE 路由广播 session-update / activity / stats
|
|
21
|
+
* 5. 定时清扫:idle → completed 的状态迁移
|
|
22
|
+
*
|
|
23
|
+
* 数据来源:proxy-server 的 finalizeLog(含 AccessKey 与普通路由两条分支)。
|
|
24
|
+
* 服务自身持有运行时态,不依赖 dbManager 做状态推断(兼容 global / access-key 两种会话存储)。
|
|
25
|
+
* dbManager 仅用于 attach 时种子化已有全局 Session。
|
|
26
|
+
*/
|
|
27
|
+
const events_1 = require("events");
|
|
28
|
+
const activity_extractor_1 = require("./activity-extractor");
|
|
29
|
+
const session_meta_1 = require("./session-meta");
|
|
30
|
+
const notifier_1 = require("../notifier");
|
|
31
|
+
const ACTIVE_WINDOW_MS = 60000; // 最近 60s 内有活动 → active
|
|
32
|
+
const IDLE_WINDOW_MS = 10 * 60000; // 10min 内有活动但超过 active 窗口 → idle;超出 → completed
|
|
33
|
+
const SWEEP_INTERVAL_MS = 15000; // 每 15s 扫描一次状态迁移
|
|
34
|
+
const MAX_EVENTS_PER_SESSION = 200;
|
|
35
|
+
const MAX_GLOBAL_EVENTS = 500;
|
|
36
|
+
const RECENT_WINDOW_FOR_STATS_MS = 60000;
|
|
37
|
+
// 「一轮结束」OS 通知冷却:同一会话在此窗口内的多次 active→idle(典型来自主轮之后的
|
|
38
|
+
// 后台/计数/compact 等续发请求,每个 end_turn 都会再触发一次迁移)只弹一次,避免重复打扰。
|
|
39
|
+
const NOTIFY_COOLDOWN_MS = 60000;
|
|
40
|
+
// 启动重建:只回填最近 1h 内有活动的会话,每会话最多回填 N 条事件,避免重复解析大量历史分片
|
|
41
|
+
const REBUILD_SINCE_MS = 60 * 60000;
|
|
42
|
+
const REBUILD_EVENTS_PER_SESSION = 30;
|
|
43
|
+
// 点节点按需重建:取更宽松的窗口,保证老节点点开后仍能看到近期活动路径
|
|
44
|
+
const ONDEMAND_SINCE_MS = 24 * 60 * 60000;
|
|
45
|
+
class AgentMapService extends events_1.EventEmitter {
|
|
46
|
+
constructor() {
|
|
47
|
+
super();
|
|
48
|
+
Object.defineProperty(this, "db", {
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: null
|
|
53
|
+
});
|
|
54
|
+
Object.defineProperty(this, "states", {
|
|
55
|
+
enumerable: true,
|
|
56
|
+
configurable: true,
|
|
57
|
+
writable: true,
|
|
58
|
+
value: new Map()
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(this, "sessionEvents", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
value: new Map()
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(this, "globalEvents", {
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
value: []
|
|
71
|
+
});
|
|
72
|
+
Object.defineProperty(this, "sweepTimer", {
|
|
73
|
+
enumerable: true,
|
|
74
|
+
configurable: true,
|
|
75
|
+
writable: true,
|
|
76
|
+
value: null
|
|
77
|
+
});
|
|
78
|
+
// 任务结束 OS 通知(服务端交付)。开关开启后始终弹,不再区分页面是否后台。
|
|
79
|
+
Object.defineProperty(this, "notifyEnabled", {
|
|
80
|
+
enumerable: true,
|
|
81
|
+
configurable: true,
|
|
82
|
+
writable: true,
|
|
83
|
+
value: false
|
|
84
|
+
});
|
|
85
|
+
// 阈值可调(供测试/配置覆盖)
|
|
86
|
+
Object.defineProperty(this, "activeWindowMs", {
|
|
87
|
+
enumerable: true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
writable: true,
|
|
90
|
+
value: ACTIVE_WINDOW_MS
|
|
91
|
+
});
|
|
92
|
+
Object.defineProperty(this, "idleWindowMs", {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
writable: true,
|
|
96
|
+
value: IDLE_WINDOW_MS
|
|
97
|
+
});
|
|
98
|
+
this.setMaxListeners(50);
|
|
99
|
+
}
|
|
100
|
+
/** 由 main.ts 在 dbManager 就绪后调用 */
|
|
101
|
+
attach(db) {
|
|
102
|
+
var _a, _b;
|
|
103
|
+
this.db = db;
|
|
104
|
+
// 恢复持久化的通知开关,使重启后无需浏览器即可正常弹通知
|
|
105
|
+
try {
|
|
106
|
+
this.notifyEnabled = !!((_b = (_a = db.getConfig) === null || _a === void 0 ? void 0 : _a.call(db)) === null || _b === void 0 ? void 0 : _b.agentMapNotifyEnabled);
|
|
107
|
+
}
|
|
108
|
+
catch ( /* ignore */_c) { /* ignore */ }
|
|
109
|
+
this.seedFromDb().catch(err => {
|
|
110
|
+
console.error('[AgentMap] seed error:', err);
|
|
111
|
+
});
|
|
112
|
+
if (!this.sweepTimer) {
|
|
113
|
+
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
|
114
|
+
// 不阻止进程退出
|
|
115
|
+
if (typeof this.sweepTimer.unref === 'function')
|
|
116
|
+
this.sweepTimer.unref();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
seedFromDb() {
|
|
120
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
121
|
+
var _a;
|
|
122
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getSessions))
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
const sessions = yield this.db.getSessions(undefined, 500, 0);
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
for (const s of sessions) {
|
|
128
|
+
if (!s || !s.id)
|
|
129
|
+
continue;
|
|
130
|
+
if (this.states.has(s.id))
|
|
131
|
+
continue;
|
|
132
|
+
const lastRequestAt = s.lastRequestAt || s.firstRequestAt || now;
|
|
133
|
+
const { status, reason } = this.inferStatus({
|
|
134
|
+
lastRequestAt,
|
|
135
|
+
lastStatusCode: s.lastStatusCode,
|
|
136
|
+
inFlight: 0,
|
|
137
|
+
now,
|
|
138
|
+
});
|
|
139
|
+
this.states.set(s.id, {
|
|
140
|
+
sessionId: s.id,
|
|
141
|
+
agent: s.targetType || 'claude-code',
|
|
142
|
+
source: 'global',
|
|
143
|
+
title: s.title,
|
|
144
|
+
firstRequestAt: s.firstRequestAt || lastRequestAt,
|
|
145
|
+
lastRequestAt,
|
|
146
|
+
requestCount: s.requestCount || 0,
|
|
147
|
+
totalTokens: s.totalTokens || 0,
|
|
148
|
+
lastToolName: s.lastToolName,
|
|
149
|
+
lastActivitySummary: s.lastActivitySummary,
|
|
150
|
+
lastStatusCode: s.lastStatusCode,
|
|
151
|
+
lastModel: s.model,
|
|
152
|
+
status,
|
|
153
|
+
statusReason: reason,
|
|
154
|
+
inFlight: 0,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
this.broadcastStats();
|
|
158
|
+
// 从 Session Log 重建最近一批会话的活动事件(填充全局活动流 + 详情路径),见 rebuildSessionEvents
|
|
159
|
+
this.rebuildRecentEvents().catch(err => console.error('[AgentMap] rebuildRecentEvents error:', err));
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.error('[AgentMap] seedFromDb error:', err);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 从已加载的请求日志现算活动事件(与 onFinalized 同源解析,含连续相同 prompt 去重)。
|
|
168
|
+
* 不再触发任何 DB 读取,供 rebuildSessionEvents / rebuildRecentEvents 复用。
|
|
169
|
+
*/
|
|
170
|
+
buildEventsFromLogs(sessionId, agent, logs) {
|
|
171
|
+
if (!Array.isArray(logs) || logs.length === 0)
|
|
172
|
+
return [];
|
|
173
|
+
const sorted = [...logs].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const log of sorted) {
|
|
176
|
+
const usage = log.usage;
|
|
177
|
+
const tokensDelta = (usage === null || usage === void 0 ? void 0 : usage.totalTokens) || (((usage === null || usage === void 0 ? void 0 : usage.inputTokens) || 0) + ((usage === null || usage === void 0 ? void 0 : usage.outputTokens) || 0));
|
|
178
|
+
const input = {
|
|
179
|
+
sessionId,
|
|
180
|
+
agent,
|
|
181
|
+
timestamp: log.timestamp,
|
|
182
|
+
source: 'global',
|
|
183
|
+
body: log.body,
|
|
184
|
+
downstreamResponseBody: log.downstreamResponseBody,
|
|
185
|
+
responseBody: log.responseBody,
|
|
186
|
+
statusCode: log.statusCode,
|
|
187
|
+
tokensDelta,
|
|
188
|
+
};
|
|
189
|
+
const evs = (0, activity_extractor_1.extractActivityEvents)(input);
|
|
190
|
+
// 连续相同 prompt 去重(与 onFinalized 一致)
|
|
191
|
+
for (const e of evs) {
|
|
192
|
+
if (e.kind === 'prompt' && out.length > 0) {
|
|
193
|
+
const last = out[out.length - 1];
|
|
194
|
+
if (last.kind === 'prompt' && last.summary === e.summary)
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
out.push(e);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (out.length > MAX_EVENTS_PER_SESSION)
|
|
201
|
+
out.splice(0, out.length - MAX_EVENTS_PER_SESSION);
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 从某会话的请求日志现算重建活动事件(与 onFinalized 同源解析,含连续相同 prompt 去重)。
|
|
206
|
+
* 用于重启后恢复活动路径/全局活动流;失败或无日志返回 []。
|
|
207
|
+
* since 下推到 DB 层,先用索引 ref 的时间戳过滤,老分片完全不读。
|
|
208
|
+
*/
|
|
209
|
+
rebuildSessionEvents(sessionId, agent, since) {
|
|
210
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
211
|
+
var _a;
|
|
212
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getLogsBySessionId))
|
|
213
|
+
return [];
|
|
214
|
+
try {
|
|
215
|
+
const logs = yield this.db.getLogsBySessionId(sessionId, MAX_EVENTS_PER_SESSION, since);
|
|
216
|
+
return this.buildEventsFromLogs(sessionId, agent, logs);
|
|
217
|
+
}
|
|
218
|
+
catch (_b) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 启动时为最近 1 小时内有活动的 global 会话重建活动事件,填充全局活动流(fire-and-forget)。
|
|
225
|
+
* 通过 getRecentLogsBySessions 跨会话合并分片读取,每个分片只 loadLogShard 一次,
|
|
226
|
+
* 避免「每会话独立读同一分片」导致的重复解析与内存爆炸。
|
|
227
|
+
*/
|
|
228
|
+
rebuildRecentEvents() {
|
|
229
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
230
|
+
if (!this.db)
|
|
231
|
+
return;
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
const since = now - REBUILD_SINCE_MS;
|
|
234
|
+
const recentStates = Array.from(this.states.values())
|
|
235
|
+
.filter(st => st.source === 'global' && st.lastRequestAt >= since)
|
|
236
|
+
.sort((a, b) => b.lastRequestAt - a.lastRequestAt);
|
|
237
|
+
if (recentStates.length === 0) {
|
|
238
|
+
this.emit('stats', this.computeStats());
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
let logsBySession;
|
|
242
|
+
if (this.db.getRecentLogsBySessions) {
|
|
243
|
+
// 批量回填:跨会话分片去重
|
|
244
|
+
logsBySession = yield this.db.getRecentLogsBySessions(recentStates.map(st => st.sessionId), { since, perSessionLimit: REBUILD_EVENTS_PER_SESSION });
|
|
245
|
+
}
|
|
246
|
+
else if (this.db.getLogsBySessionId) {
|
|
247
|
+
// 兼容降级:逐会话回填(仍带 since 时间窗过滤)
|
|
248
|
+
logsBySession = new Map();
|
|
249
|
+
for (const st of recentStates) {
|
|
250
|
+
try {
|
|
251
|
+
const logs = yield this.db.getLogsBySessionId(st.sessionId, REBUILD_EVENTS_PER_SESSION, since);
|
|
252
|
+
if (logs.length > 0)
|
|
253
|
+
logsBySession.set(st.sessionId, logs);
|
|
254
|
+
}
|
|
255
|
+
catch (_a) {
|
|
256
|
+
// 忽略单会话失败
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
this.emit('stats', this.computeStats());
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
for (const st of recentStates) {
|
|
265
|
+
const logs = logsBySession.get(st.sessionId);
|
|
266
|
+
if (!logs || logs.length === 0)
|
|
267
|
+
continue;
|
|
268
|
+
const evs = this.buildEventsFromLogs(st.sessionId, st.agent, logs);
|
|
269
|
+
if (evs.length > 0) {
|
|
270
|
+
this.sessionEvents.set(st.sessionId, evs);
|
|
271
|
+
this.globalEvents.push(...evs);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (this.globalEvents.length > MAX_GLOBAL_EVENTS) {
|
|
275
|
+
this.globalEvents.splice(0, this.globalEvents.length - MAX_GLOBAL_EVENTS);
|
|
276
|
+
}
|
|
277
|
+
this.globalEvents.sort((a, b) => a.ts - b.ts);
|
|
278
|
+
this.emit('stats', this.computeStats()); // 通知已连接客户端活动流已就绪
|
|
279
|
+
// 把重建出的最近 100 条全局活动按时间正序回放给已连接客户端(前端 onActivity 会倒序 prepend,正好 newest 在顶)
|
|
280
|
+
// 解决「客户端在 rebuild 完成前就连上、init 快照里 feed 为空」的时序问题
|
|
281
|
+
for (const e of this.globalEvents.slice(-100)) {
|
|
282
|
+
this.emit('activity', e);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
// ============ 在途请求注册表 ============
|
|
287
|
+
startRequest(sessionId, agent, opts) {
|
|
288
|
+
if (!sessionId || sessionId === '-')
|
|
289
|
+
return;
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
let st = this.states.get(sessionId);
|
|
292
|
+
if (!st) {
|
|
293
|
+
st = {
|
|
294
|
+
sessionId,
|
|
295
|
+
agent,
|
|
296
|
+
source: (opts === null || opts === void 0 ? void 0 : opts.source) || 'global',
|
|
297
|
+
keyId: opts === null || opts === void 0 ? void 0 : opts.keyId,
|
|
298
|
+
keyName: opts === null || opts === void 0 ? void 0 : opts.keyName,
|
|
299
|
+
title: opts === null || opts === void 0 ? void 0 : opts.title,
|
|
300
|
+
firstRequestAt: now,
|
|
301
|
+
lastRequestAt: now,
|
|
302
|
+
requestCount: 0,
|
|
303
|
+
totalTokens: 0,
|
|
304
|
+
status: 'active',
|
|
305
|
+
statusReason: 'in-flight',
|
|
306
|
+
inFlight: 0,
|
|
307
|
+
};
|
|
308
|
+
this.states.set(sessionId, st);
|
|
309
|
+
}
|
|
310
|
+
st.inFlight += 1;
|
|
311
|
+
st.lastRequestAt = now;
|
|
312
|
+
// 有在途请求必然 active
|
|
313
|
+
if (st.status !== 'active') {
|
|
314
|
+
st.status = 'active';
|
|
315
|
+
st.statusReason = 'in-flight';
|
|
316
|
+
}
|
|
317
|
+
this.emitSession(st);
|
|
318
|
+
}
|
|
319
|
+
endRequest(sessionId) {
|
|
320
|
+
if (!sessionId || sessionId === '-')
|
|
321
|
+
return;
|
|
322
|
+
const st = this.states.get(sessionId);
|
|
323
|
+
if (!st)
|
|
324
|
+
return;
|
|
325
|
+
st.inFlight = Math.max(0, st.inFlight - 1);
|
|
326
|
+
// 不立即改 status,由 onFinalized 的重算决定
|
|
327
|
+
}
|
|
328
|
+
// ============ 请求收尾:抽事件 + 重算状态 + 广播 ============
|
|
329
|
+
onFinalized(ctx) {
|
|
330
|
+
if (!ctx.sessionId || ctx.sessionId === '-')
|
|
331
|
+
return;
|
|
332
|
+
const extractInput = {
|
|
333
|
+
sessionId: ctx.sessionId,
|
|
334
|
+
agent: ctx.agent,
|
|
335
|
+
timestamp: ctx.timestamp,
|
|
336
|
+
source: ctx.source,
|
|
337
|
+
keyId: ctx.keyId,
|
|
338
|
+
keyName: ctx.keyName,
|
|
339
|
+
body: ctx.body,
|
|
340
|
+
downstreamResponseBody: ctx.downstreamResponseBody,
|
|
341
|
+
responseBody: ctx.responseBody,
|
|
342
|
+
statusCode: ctx.statusCode,
|
|
343
|
+
tokensDelta: ctx.tokensDelta,
|
|
344
|
+
};
|
|
345
|
+
const rawEvents = (0, activity_extractor_1.extractActivityEvents)(extractInput);
|
|
346
|
+
// 跨轮次 prompt 去重:若本轮 prompt 与该会话「最近一次真正写入的 prompt」文本相同,则丢弃。
|
|
347
|
+
// 比旧逻辑(仅当上一条已记录事件本身也是相同 prompt 才丢)更稳:无论两条相同提问之间
|
|
348
|
+
// 夹了多少 tool_use / response(工具循环 / 客户端重试 / 末条 user 同时含 text+tool_result)都能命中。
|
|
349
|
+
const existingState = this.states.get(ctx.sessionId);
|
|
350
|
+
const prevPromptSummary = existingState === null || existingState === void 0 ? void 0 : existingState.lastPromptSummary;
|
|
351
|
+
const firstPromptIdx = rawEvents.findIndex(e => e.kind === 'prompt');
|
|
352
|
+
let events = rawEvents;
|
|
353
|
+
let recordedPromptSummary = prevPromptSummary;
|
|
354
|
+
if (firstPromptIdx >= 0
|
|
355
|
+
&& prevPromptSummary
|
|
356
|
+
&& rawEvents[firstPromptIdx].summary === prevPromptSummary) {
|
|
357
|
+
events = rawEvents.filter((_, i) => i !== firstPromptIdx);
|
|
358
|
+
}
|
|
359
|
+
else if (firstPromptIdx >= 0) {
|
|
360
|
+
recordedPromptSummary = rawEvents[firstPromptIdx].summary;
|
|
361
|
+
}
|
|
362
|
+
const { summary, toolName } = (0, activity_extractor_1.deriveLastActivity)(events);
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
let st = this.states.get(ctx.sessionId);
|
|
365
|
+
if (!st) {
|
|
366
|
+
st = {
|
|
367
|
+
sessionId: ctx.sessionId,
|
|
368
|
+
agent: ctx.agent,
|
|
369
|
+
source: ctx.source || 'global',
|
|
370
|
+
keyId: ctx.keyId,
|
|
371
|
+
keyName: ctx.keyName,
|
|
372
|
+
title: ctx.title,
|
|
373
|
+
firstRequestAt: ctx.timestamp,
|
|
374
|
+
lastRequestAt: ctx.timestamp,
|
|
375
|
+
requestCount: 0,
|
|
376
|
+
totalTokens: 0,
|
|
377
|
+
status: 'active',
|
|
378
|
+
inFlight: 0,
|
|
379
|
+
};
|
|
380
|
+
this.states.set(ctx.sessionId, st);
|
|
381
|
+
}
|
|
382
|
+
st.agent = ctx.agent;
|
|
383
|
+
st.source = ctx.source || st.source || 'global';
|
|
384
|
+
if (ctx.keyId)
|
|
385
|
+
st.keyId = ctx.keyId;
|
|
386
|
+
if (ctx.keyName)
|
|
387
|
+
st.keyName = ctx.keyName;
|
|
388
|
+
if (ctx.title && !st.title)
|
|
389
|
+
st.title = ctx.title;
|
|
390
|
+
st.lastPromptSummary = recordedPromptSummary;
|
|
391
|
+
st.lastRequestAt = ctx.timestamp;
|
|
392
|
+
st.requestCount += 1;
|
|
393
|
+
if (ctx.tokensDelta)
|
|
394
|
+
st.totalTokens += ctx.tokensDelta;
|
|
395
|
+
if (ctx.model)
|
|
396
|
+
st.lastModel = ctx.model;
|
|
397
|
+
if (ctx.statusCode != null)
|
|
398
|
+
st.lastStatusCode = ctx.statusCode;
|
|
399
|
+
if (summary)
|
|
400
|
+
st.lastActivitySummary = summary;
|
|
401
|
+
if (toolName)
|
|
402
|
+
st.lastToolName = toolName;
|
|
403
|
+
// 本轮响应是否表示「一轮结束」(精确信号,替代纯时间窗)
|
|
404
|
+
const turnEnd = (0, activity_extractor_1.detectTurnEnd)(ctx.agent, ctx.downstreamResponseBody, ctx.responseBody);
|
|
405
|
+
st.lastTurnEnd = turnEnd;
|
|
406
|
+
// 重算状态(onFinalized 时请求已结束,inFlight 会被 endRequest 减,但可能尚未调用)
|
|
407
|
+
const prevStatus = st.status;
|
|
408
|
+
const { status, reason } = this.inferStatus({
|
|
409
|
+
lastRequestAt: st.lastRequestAt,
|
|
410
|
+
lastStatusCode: st.lastStatusCode,
|
|
411
|
+
inFlight: st.inFlight,
|
|
412
|
+
now,
|
|
413
|
+
turnEnd,
|
|
414
|
+
});
|
|
415
|
+
st.status = status;
|
|
416
|
+
st.statusReason = reason;
|
|
417
|
+
this.maybeNotifyTurnEnd(prevStatus, st);
|
|
418
|
+
// 记录事件
|
|
419
|
+
if (events.length > 0) {
|
|
420
|
+
this.recordEvents(ctx.sessionId, events);
|
|
421
|
+
for (const e of events) {
|
|
422
|
+
this.emit('activity', e);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
this.emitSession(st);
|
|
426
|
+
// 异步解析本地会话元信息(项目路径 + 原始标题);仅 global 来源、且未解析过
|
|
427
|
+
this.enrichSession(st);
|
|
428
|
+
}
|
|
429
|
+
/** 从本机 Claude/Codex 会话存储读取项目路径与原始标题并回填(access-key 不解析) */
|
|
430
|
+
enrichSession(st) {
|
|
431
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
432
|
+
if (st.source === 'access-key' || st.metaResolved)
|
|
433
|
+
return;
|
|
434
|
+
st.metaResolved = true;
|
|
435
|
+
try {
|
|
436
|
+
const meta = yield (0, session_meta_1.resolveSessionMeta)(st.sessionId, st.agent);
|
|
437
|
+
let changed = false;
|
|
438
|
+
if (meta.projectPath && !st.projectPath) {
|
|
439
|
+
st.projectPath = meta.projectPath;
|
|
440
|
+
changed = true;
|
|
441
|
+
}
|
|
442
|
+
// 原始标题更标准,命中即覆盖日志截取的标题
|
|
443
|
+
if (meta.title) {
|
|
444
|
+
st.title = meta.title;
|
|
445
|
+
changed = true;
|
|
446
|
+
}
|
|
447
|
+
if (changed)
|
|
448
|
+
this.emitSession(st);
|
|
449
|
+
}
|
|
450
|
+
catch ( /* ignore */_a) { /* ignore */ }
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** 按需解析(供 REST 端点 / 详情 popover)。返回 source 便于前端给出恰当提示 */
|
|
454
|
+
getSessionMeta(sessionId) {
|
|
455
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
456
|
+
const st = this.states.get(sessionId);
|
|
457
|
+
const source = st ? st.source : 'unknown';
|
|
458
|
+
if (source === 'access-key' || source === 'unknown') {
|
|
459
|
+
return { source, projectPath: st === null || st === void 0 ? void 0 : st.projectPath, title: st === null || st === void 0 ? void 0 : st.title };
|
|
460
|
+
}
|
|
461
|
+
// global:命中缓存或现解析
|
|
462
|
+
const meta = yield (0, session_meta_1.resolveSessionMeta)(sessionId, (st === null || st === void 0 ? void 0 : st.agent) || 'claude-code');
|
|
463
|
+
if (st) {
|
|
464
|
+
if (meta.projectPath && !st.projectPath)
|
|
465
|
+
st.projectPath = meta.projectPath;
|
|
466
|
+
if (meta.title)
|
|
467
|
+
st.title = meta.title;
|
|
468
|
+
st.metaResolved = true;
|
|
469
|
+
}
|
|
470
|
+
return { source, projectPath: meta.projectPath || (st === null || st === void 0 ? void 0 : st.projectPath), title: meta.title || (st === null || st === void 0 ? void 0 : st.title) };
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
// ============ 任务结束 OS 通知 ============
|
|
474
|
+
setNotifyEnabled(enabled) {
|
|
475
|
+
this.notifyEnabled = !!enabled;
|
|
476
|
+
this.persistNotifyEnabled(this.notifyEnabled);
|
|
477
|
+
}
|
|
478
|
+
/** 异步把通知开关写入 AppConfig(read-modify-write,best-effort,不阻塞调用) */
|
|
479
|
+
persistNotifyEnabled(enabled) {
|
|
480
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
481
|
+
var _a, _b;
|
|
482
|
+
if (!((_a = this.db) === null || _a === void 0 ? void 0 : _a.getConfig) || !((_b = this.db) === null || _b === void 0 ? void 0 : _b.updateConfig))
|
|
483
|
+
return;
|
|
484
|
+
try {
|
|
485
|
+
const cfg = this.db.getConfig();
|
|
486
|
+
if (cfg.agentMapNotifyEnabled === enabled)
|
|
487
|
+
return;
|
|
488
|
+
cfg.agentMapNotifyEnabled = enabled;
|
|
489
|
+
yield this.db.updateConfig(cfg);
|
|
490
|
+
}
|
|
491
|
+
catch ( /* ignore */_c) { /* ignore */ }
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/** 兼容旧端点:不再区分前后台,调用为 no-op(开关开启后始终弹) */
|
|
495
|
+
setPageHidden(_hidden) { }
|
|
496
|
+
getNotifyEnabled() { return this.notifyEnabled; }
|
|
497
|
+
/** 发一条测试通知(供 UI「测试」按钮验证 OS 是否真弹) */
|
|
498
|
+
notifyTest() {
|
|
499
|
+
(0, notifier_1.notify)({ title: '🔔 AICodeSwitch', body: '测试通知:通知功能可用' });
|
|
500
|
+
}
|
|
501
|
+
/** active → idle 迁移时,只要开关开启就弹 OS 通知(一轮工作结束)。不再看页面是否后台。 */
|
|
502
|
+
maybeNotifyTurnEnd(prevStatus, st) {
|
|
503
|
+
if (!this.notifyEnabled)
|
|
504
|
+
return;
|
|
505
|
+
if (prevStatus !== 'active' || st.status !== 'idle')
|
|
506
|
+
return;
|
|
507
|
+
// 499 = 用户主动放弃停止任务,属于预期行为,不弹系统通知
|
|
508
|
+
if (st.lastStatusCode === 499)
|
|
509
|
+
return;
|
|
510
|
+
// 冷却去重:主轮结束后常伴有后台/计数/compact 等续发请求,各自 end_turn 会重复触发 active→idle,
|
|
511
|
+
// 在冷却窗口内只弹一次,避免同一任务弹两条通知。
|
|
512
|
+
const now = Date.now();
|
|
513
|
+
if (st.lastNotifyAt && now - st.lastNotifyAt < NOTIFY_COOLDOWN_MS)
|
|
514
|
+
return;
|
|
515
|
+
st.lastNotifyAt = now;
|
|
516
|
+
const agentName = st.agent === 'codex' ? 'Codex' : 'Claude Code';
|
|
517
|
+
(0, notifier_1.notify)({
|
|
518
|
+
title: `✅ AICodeSwitch · ${agentName}`,
|
|
519
|
+
body: st.title || st.lastActivitySummary || '任务已暂停,等待下一步',
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
recordEvents(sessionId, events) {
|
|
523
|
+
const arr = this.sessionEvents.get(sessionId) || [];
|
|
524
|
+
arr.push(...events);
|
|
525
|
+
if (arr.length > MAX_EVENTS_PER_SESSION) {
|
|
526
|
+
arr.splice(0, arr.length - MAX_EVENTS_PER_SESSION);
|
|
527
|
+
}
|
|
528
|
+
this.sessionEvents.set(sessionId, arr);
|
|
529
|
+
this.globalEvents.push(...events);
|
|
530
|
+
if (this.globalEvents.length > MAX_GLOBAL_EVENTS) {
|
|
531
|
+
this.globalEvents.splice(0, this.globalEvents.length - MAX_GLOBAL_EVENTS);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ============ 状态推断(纯函数,基于活跃度) ============
|
|
535
|
+
inferStatus(args) {
|
|
536
|
+
// 在途请求 → active
|
|
537
|
+
if (args.inFlight > 0)
|
|
538
|
+
return { status: 'active', reason: 'in-flight' };
|
|
539
|
+
// 末次失败 → error(即使时间较近也标 error,下一次成功会刷回 active)
|
|
540
|
+
if (args.lastStatusCode != null && args.lastStatusCode >= 500) {
|
|
541
|
+
return { status: 'error', reason: `upstream ${args.lastStatusCode}` };
|
|
542
|
+
}
|
|
543
|
+
const elapsed = args.now - args.lastRequestAt;
|
|
544
|
+
// 499 = 客户端主动断开(用户放弃停止任务):视为「已取消」,立即停止脉冲进入 idle,过 idleWindow 再 completed
|
|
545
|
+
if (args.lastStatusCode === 499) {
|
|
546
|
+
if (elapsed <= this.idleWindowMs)
|
|
547
|
+
return { status: 'idle', reason: 'client cancelled' };
|
|
548
|
+
return { status: 'completed', reason: 'cancelled earlier' };
|
|
549
|
+
}
|
|
550
|
+
// 精确信号:本轮响应明确「结束」→ 立即停止脉冲,进入 idle(超时再 completed)
|
|
551
|
+
if (args.turnEnd === true) {
|
|
552
|
+
if (elapsed <= this.idleWindowMs)
|
|
553
|
+
return { status: 'idle', reason: 'turn ended' };
|
|
554
|
+
return { status: 'completed', reason: 'inactive' };
|
|
555
|
+
}
|
|
556
|
+
// 仍将继续(tool_use)或信号未知 → 回退到活跃时间窗
|
|
557
|
+
if (elapsed <= this.activeWindowMs) {
|
|
558
|
+
return { status: 'active', reason: args.turnEnd === false ? 'tool loop' : 'recent request' };
|
|
559
|
+
}
|
|
560
|
+
if (elapsed <= this.idleWindowMs)
|
|
561
|
+
return { status: 'idle', reason: 'no recent activity' };
|
|
562
|
+
return { status: 'completed', reason: 'inactive' };
|
|
563
|
+
}
|
|
564
|
+
// ============ 定时清扫 ============
|
|
565
|
+
sweep() {
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
let changed = false;
|
|
568
|
+
this.states.forEach(st => {
|
|
569
|
+
const prevStatus = st.status;
|
|
570
|
+
const { status, reason } = this.inferStatus({
|
|
571
|
+
lastRequestAt: st.lastRequestAt,
|
|
572
|
+
lastStatusCode: st.lastStatusCode,
|
|
573
|
+
inFlight: st.inFlight,
|
|
574
|
+
now,
|
|
575
|
+
turnEnd: st.lastTurnEnd,
|
|
576
|
+
});
|
|
577
|
+
if (status !== prevStatus) {
|
|
578
|
+
st.status = status;
|
|
579
|
+
st.statusReason = reason;
|
|
580
|
+
changed = true;
|
|
581
|
+
this.maybeNotifyTurnEnd(prevStatus, st);
|
|
582
|
+
this.emitSession(st);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
if (changed)
|
|
586
|
+
this.broadcastStats();
|
|
587
|
+
}
|
|
588
|
+
// ============ 广播 ============
|
|
589
|
+
emitSession(st) {
|
|
590
|
+
this.emit('session-update', this.toMapItem(st));
|
|
591
|
+
this.broadcastStats();
|
|
592
|
+
}
|
|
593
|
+
broadcastStats() {
|
|
594
|
+
this.emit('stats', this.computeStats());
|
|
595
|
+
}
|
|
596
|
+
// ============ 读取(供 REST / SSE init) ============
|
|
597
|
+
getSnapshot() {
|
|
598
|
+
return {
|
|
599
|
+
sessions: Array.from(this.states.values()).map(s => this.toMapItem(s)),
|
|
600
|
+
events: this.getRecentGlobalEvents(100),
|
|
601
|
+
stats: this.computeStats(),
|
|
602
|
+
serverTime: Date.now(),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
getSessionEvents(sessionId, since) {
|
|
606
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
607
|
+
var _a;
|
|
608
|
+
let arr = this.sessionEvents.get(sessionId);
|
|
609
|
+
// 按需重建:缓冲为空、会话存在、且能读日志 → 从 Session Log 现算(重启后点开老节点)
|
|
610
|
+
if ((!arr || arr.length === 0) && this.states.has(sessionId) && ((_a = this.db) === null || _a === void 0 ? void 0 : _a.getLogsBySessionId)) {
|
|
611
|
+
const st = this.states.get(sessionId);
|
|
612
|
+
if (st.source === 'global') {
|
|
613
|
+
// 按需重建:取最近 24h,既保证老节点点开后能看到活动路径,又避免解析全部历史
|
|
614
|
+
const evs = yield this.rebuildSessionEvents(sessionId, st.agent, Date.now() - ONDEMAND_SINCE_MS);
|
|
615
|
+
if (evs.length > 0) {
|
|
616
|
+
this.sessionEvents.set(sessionId, evs);
|
|
617
|
+
arr = evs;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const base = arr || [];
|
|
622
|
+
if (since == null)
|
|
623
|
+
return base.slice(-MAX_EVENTS_PER_SESSION);
|
|
624
|
+
return base.filter(e => e.ts > since);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
getRecentGlobalEvents(limit) {
|
|
628
|
+
return this.globalEvents.slice(-limit).reverse();
|
|
629
|
+
}
|
|
630
|
+
toMapItem(st) {
|
|
631
|
+
return {
|
|
632
|
+
sessionId: st.sessionId,
|
|
633
|
+
agent: st.agent,
|
|
634
|
+
source: st.source,
|
|
635
|
+
keyId: st.keyId,
|
|
636
|
+
keyName: st.keyName,
|
|
637
|
+
title: st.title,
|
|
638
|
+
status: st.status,
|
|
639
|
+
statusReason: st.statusReason,
|
|
640
|
+
firstRequestAt: st.firstRequestAt,
|
|
641
|
+
lastRequestAt: st.lastRequestAt,
|
|
642
|
+
requestCount: st.requestCount,
|
|
643
|
+
totalTokens: st.totalTokens,
|
|
644
|
+
lastToolName: st.lastToolName,
|
|
645
|
+
lastActivitySummary: st.lastActivitySummary,
|
|
646
|
+
lastStatusCode: st.lastStatusCode,
|
|
647
|
+
lastModel: st.lastModel,
|
|
648
|
+
inFlight: st.inFlight,
|
|
649
|
+
projectPath: st.projectPath,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
computeStats() {
|
|
653
|
+
let active = 0, idle = 0, completed = 0, error = 0, inFlight = 0;
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
for (const st of this.states.values()) {
|
|
656
|
+
inFlight += st.inFlight;
|
|
657
|
+
switch (st.status) {
|
|
658
|
+
case 'active':
|
|
659
|
+
active++;
|
|
660
|
+
break;
|
|
661
|
+
case 'idle':
|
|
662
|
+
idle++;
|
|
663
|
+
break;
|
|
664
|
+
case 'completed':
|
|
665
|
+
completed++;
|
|
666
|
+
break;
|
|
667
|
+
case 'error':
|
|
668
|
+
error++;
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const recentCutoff = now - RECENT_WINDOW_FOR_STATS_MS;
|
|
673
|
+
let recentToolCalls = 0;
|
|
674
|
+
let recentTokens = 0;
|
|
675
|
+
for (const e of this.globalEvents) {
|
|
676
|
+
if (e.ts < recentCutoff)
|
|
677
|
+
continue;
|
|
678
|
+
if (e.kind === 'tool_use')
|
|
679
|
+
recentToolCalls++;
|
|
680
|
+
if (e.tokensDelta)
|
|
681
|
+
recentTokens += e.tokensDelta;
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
totalSessions: this.states.size,
|
|
685
|
+
activeSessions: active,
|
|
686
|
+
idleSessions: idle,
|
|
687
|
+
completedSessions: completed,
|
|
688
|
+
errorSessions: error,
|
|
689
|
+
inFlightRequests: inFlight,
|
|
690
|
+
recentToolCalls,
|
|
691
|
+
recentTokens,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
destroy() {
|
|
695
|
+
if (this.sweepTimer) {
|
|
696
|
+
clearInterval(this.sweepTimer);
|
|
697
|
+
this.sweepTimer = null;
|
|
698
|
+
}
|
|
699
|
+
this.states.clear();
|
|
700
|
+
this.sessionEvents.clear();
|
|
701
|
+
this.globalEvents = [];
|
|
702
|
+
this.removeAllListeners();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
exports.AgentMapService = AgentMapService;
|
|
706
|
+
// 全局单例(在 dbManager 就绪前 no-op;attach 后激活)
|
|
707
|
+
exports.agentMapService = new AgentMapService();
|