shennian 0.2.6 → 0.2.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/dist/bin/shennian.d.ts +2 -0
- package/dist/bin/shennian.js +2 -0
- package/dist/src/agents/adapter.d.ts +35 -0
- package/dist/src/agents/adapter.js +19 -0
- package/dist/src/agents/claude.d.ts +25 -0
- package/dist/src/agents/claude.js +234 -0
- package/dist/src/agents/codex.d.ts +52 -0
- package/dist/src/agents/codex.js +829 -0
- package/dist/src/agents/command-spec.d.ts +36 -0
- package/dist/src/agents/command-spec.js +303 -0
- package/dist/src/agents/cursor.d.ts +22 -0
- package/dist/src/agents/cursor.js +248 -0
- package/dist/src/agents/custom.d.ts +30 -0
- package/dist/src/agents/custom.js +210 -0
- package/dist/src/agents/detect.d.ts +9 -0
- package/dist/src/agents/detect.js +46 -0
- package/dist/src/agents/gemini.d.ts +17 -0
- package/dist/src/agents/gemini.js +172 -0
- package/dist/src/agents/model-registry/cache.d.ts +4 -0
- package/dist/src/agents/model-registry/cache.js +37 -0
- package/dist/src/agents/model-registry/discovery.d.ts +4 -0
- package/dist/src/agents/model-registry/discovery.js +166 -0
- package/dist/src/agents/model-registry/parsers.d.ts +11 -0
- package/dist/src/agents/model-registry/parsers.js +349 -0
- package/dist/src/agents/model-registry/runner.d.ts +6 -0
- package/dist/src/agents/model-registry/runner.js +29 -0
- package/dist/src/agents/model-registry/service.d.ts +6 -0
- package/dist/src/agents/model-registry/service.js +71 -0
- package/dist/src/agents/model-registry/types.d.ts +30 -0
- package/dist/src/agents/model-registry/types.js +8 -0
- package/dist/src/agents/model-registry.d.ts +13 -0
- package/dist/src/agents/model-registry.js +15 -0
- package/dist/src/agents/openclaw.d.ts +20 -0
- package/dist/src/agents/openclaw.js +273 -0
- package/dist/src/agents/pi.d.ts +51 -0
- package/dist/src/agents/pi.js +793 -0
- package/dist/src/commands/agent.d.ts +2 -0
- package/dist/src/commands/agent.js +131 -0
- package/dist/src/commands/daemon.d.ts +35 -0
- package/dist/src/commands/daemon.js +612 -0
- package/dist/src/commands/pair-qr.d.ts +4 -0
- package/dist/src/commands/pair-qr.js +6 -0
- package/dist/src/commands/pair.d.ts +8 -0
- package/dist/src/commands/pair.js +162 -0
- package/dist/src/commands/upgrade.d.ts +5 -0
- package/dist/src/commands/upgrade.js +145 -0
- package/dist/src/config/index.d.ts +32 -0
- package/dist/src/config/index.js +31 -0
- package/dist/src/fs/handler.d.ts +26 -0
- package/dist/src/fs/handler.js +130 -0
- package/dist/src/fs/security.d.ts +2 -0
- package/dist/src/fs/security.js +32 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +277 -0
- package/dist/src/log-reporter.d.ts +18 -0
- package/dist/src/log-reporter.js +16 -0
- package/dist/src/native-fusion/parsers.d.ts +12 -0
- package/dist/src/native-fusion/parsers.js +687 -0
- package/dist/src/native-fusion/service.d.ts +25 -0
- package/dist/src/native-fusion/service.js +176 -0
- package/dist/src/native-fusion/state.d.ts +3 -0
- package/dist/src/native-fusion/state.js +22 -0
- package/dist/src/native-fusion/types.d.ts +24 -0
- package/dist/src/native-fusion/types.js +1 -0
- package/dist/src/region.d.ts +15 -0
- package/dist/src/region.js +99 -0
- package/dist/src/relay/client.d.ts +62 -0
- package/dist/src/relay/client.js +311 -0
- package/dist/src/session/handlers/agents.d.ts +4 -0
- package/dist/src/session/handlers/agents.js +55 -0
- package/dist/src/session/handlers/chat.d.ts +4 -0
- package/dist/src/session/handlers/chat.js +326 -0
- package/dist/src/session/handlers/control.d.ts +5 -0
- package/dist/src/session/handlers/control.js +57 -0
- package/dist/src/session/handlers/fs.d.ts +10 -0
- package/dist/src/session/handlers/fs.js +256 -0
- package/dist/src/session/manager.d.ts +28 -0
- package/dist/src/session/manager.js +174 -0
- package/dist/src/session/store.d.ts +7 -0
- package/dist/src/session/store.js +46 -0
- package/dist/src/session/types.d.ts +35 -0
- package/dist/src/session/types.js +4 -0
- package/dist/src/upgrade/engine.d.ts +64 -0
- package/dist/src/upgrade/engine.js +238 -0
- package/package.json +2 -2
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// @arch docs/architecture/cli/daemon.md#关键数据流
|
|
2
|
+
// @test src/__tests__/e2e-relay.ts
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
import { generateTraceId } from '../log-reporter.js';
|
|
5
|
+
const HEARTBEAT_TIMEOUT = 90_000;
|
|
6
|
+
const MAX_RECONNECT_DELAY = 30_000;
|
|
7
|
+
const BASE_RECONNECT_DELAY = 1_000;
|
|
8
|
+
const JITTER_FACTOR = 0.2;
|
|
9
|
+
const PING_INTERVAL = 20_000;
|
|
10
|
+
const PONG_TIMEOUT = 10_000;
|
|
11
|
+
const CONNECT_TIMEOUT = 15_000;
|
|
12
|
+
const MAX_SEND_BUFFER = 1000;
|
|
13
|
+
export class CliRelayClient {
|
|
14
|
+
options;
|
|
15
|
+
ws = null;
|
|
16
|
+
state = 'disconnected';
|
|
17
|
+
reconnectAttempts = 0;
|
|
18
|
+
heartbeatTimer = null;
|
|
19
|
+
reconnectTimer = null;
|
|
20
|
+
connectTimer = null;
|
|
21
|
+
pingTimer = null;
|
|
22
|
+
pongTimer = null;
|
|
23
|
+
disposed = false;
|
|
24
|
+
closeTrigger;
|
|
25
|
+
lastSocketError;
|
|
26
|
+
/** Buffered agent events awaiting server ack, keyed by event id */
|
|
27
|
+
sendBuffer = new Map();
|
|
28
|
+
pendingAcks = new Map();
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
}
|
|
32
|
+
connect() {
|
|
33
|
+
if (this.disposed)
|
|
34
|
+
return;
|
|
35
|
+
// Always clean up stale connection before creating a new one
|
|
36
|
+
if (this.ws) {
|
|
37
|
+
this.cleanup(false);
|
|
38
|
+
}
|
|
39
|
+
this.state = 'connecting';
|
|
40
|
+
const sep = this.options.serverUrl.includes('?') ? '&' : '?';
|
|
41
|
+
const vParam = this.options.cliVersion ? `&v=${encodeURIComponent(this.options.cliVersion)}` : '';
|
|
42
|
+
const agentsParam = this.options.agentList?.length
|
|
43
|
+
? `&agents=${encodeURIComponent(this.options.agentList.join(','))}`
|
|
44
|
+
: '';
|
|
45
|
+
const url = `${this.options.serverUrl}${sep}token=${encodeURIComponent(this.options.machineToken)}${vParam}${agentsParam}`;
|
|
46
|
+
const ws = new WebSocket(url);
|
|
47
|
+
this.ws = ws;
|
|
48
|
+
// Guard against connections that never open (DNS hang, TCP timeout, etc.)
|
|
49
|
+
this.connectTimer = setTimeout(() => {
|
|
50
|
+
if (this.ws === ws && this.state === 'connecting') {
|
|
51
|
+
this.closeTrigger = 'connect-timeout';
|
|
52
|
+
ws.terminate();
|
|
53
|
+
}
|
|
54
|
+
}, CONNECT_TIMEOUT);
|
|
55
|
+
ws.on('open', () => {
|
|
56
|
+
if (this.ws !== ws)
|
|
57
|
+
return;
|
|
58
|
+
this.clearConnectTimer();
|
|
59
|
+
this.state = 'connected';
|
|
60
|
+
this.reconnectAttempts = 0;
|
|
61
|
+
this.startHeartbeat();
|
|
62
|
+
this.startPing(ws);
|
|
63
|
+
this.flushSendBuffer();
|
|
64
|
+
this.options.onConnected?.();
|
|
65
|
+
});
|
|
66
|
+
ws.on('pong', () => {
|
|
67
|
+
if (this.ws !== ws)
|
|
68
|
+
return;
|
|
69
|
+
this.clearPongTimer();
|
|
70
|
+
this.resetHeartbeat();
|
|
71
|
+
});
|
|
72
|
+
ws.on('message', (data) => {
|
|
73
|
+
if (this.ws !== ws)
|
|
74
|
+
return;
|
|
75
|
+
let frame;
|
|
76
|
+
try {
|
|
77
|
+
frame = JSON.parse(data.toString());
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.handleFrame(frame);
|
|
83
|
+
});
|
|
84
|
+
ws.on('close', (code, reason) => {
|
|
85
|
+
if (this.ws !== ws)
|
|
86
|
+
return;
|
|
87
|
+
const phase = this.state;
|
|
88
|
+
const info = {
|
|
89
|
+
code,
|
|
90
|
+
reason: reason.toString(),
|
|
91
|
+
error: this.lastSocketError,
|
|
92
|
+
phase,
|
|
93
|
+
trigger: this.closeTrigger ?? 'socket-close',
|
|
94
|
+
reconnectAttempt: this.reconnectAttempts + 1,
|
|
95
|
+
};
|
|
96
|
+
this.cleanup(false);
|
|
97
|
+
this.options.onDisconnected?.(info);
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
});
|
|
100
|
+
ws.on('error', (error) => {
|
|
101
|
+
this.lastSocketError = error.message;
|
|
102
|
+
this.closeTrigger = 'socket-error';
|
|
103
|
+
ws.close();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
disconnect() {
|
|
107
|
+
this.disposed = true;
|
|
108
|
+
this.cleanup(true);
|
|
109
|
+
}
|
|
110
|
+
sendRes(res) {
|
|
111
|
+
if (this.state !== 'connected' || !this.ws)
|
|
112
|
+
return;
|
|
113
|
+
if (!res.traceId)
|
|
114
|
+
res.traceId = generateTraceId();
|
|
115
|
+
this.ws.send(JSON.stringify(res));
|
|
116
|
+
}
|
|
117
|
+
sendEvent(event) {
|
|
118
|
+
if (this.state !== 'connected' || !this.ws)
|
|
119
|
+
return;
|
|
120
|
+
if (!event.traceId)
|
|
121
|
+
event.traceId = generateTraceId();
|
|
122
|
+
this.ws.send(JSON.stringify(event));
|
|
123
|
+
}
|
|
124
|
+
sendBufferedEvent(event, timeoutMs = 120_000) {
|
|
125
|
+
if (!event.traceId)
|
|
126
|
+
event.traceId = generateTraceId();
|
|
127
|
+
if (!event.id) {
|
|
128
|
+
this.sendEvent(event);
|
|
129
|
+
return Promise.resolve();
|
|
130
|
+
}
|
|
131
|
+
const eventId = event.id;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const existing = this.pendingAcks.get(eventId);
|
|
134
|
+
if (existing) {
|
|
135
|
+
clearTimeout(existing.timer);
|
|
136
|
+
existing.reject(new Error('Superseded by a newer buffered event'));
|
|
137
|
+
}
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
this.pendingAcks.delete(eventId);
|
|
140
|
+
this.sendBuffer.delete(eventId);
|
|
141
|
+
reject(new Error('Relay event acknowledgement timed out'));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
this.pendingAcks.set(eventId, { resolve, reject, timer });
|
|
144
|
+
this.bufferEvent(event);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Send an agent event with at-least-once delivery guarantee.
|
|
149
|
+
* The event is buffered until the server acknowledges it.
|
|
150
|
+
* On reconnect, all unacked events are resent in order.
|
|
151
|
+
*/
|
|
152
|
+
sendAgentEvent(event) {
|
|
153
|
+
void this.sendBufferedEvent(event).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
getState() {
|
|
156
|
+
return this.state;
|
|
157
|
+
}
|
|
158
|
+
handleFrame(frame) {
|
|
159
|
+
if (frame.type === 'event' && frame.event === 'tick') {
|
|
160
|
+
this.resetHeartbeat();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (frame.type === 'res' && frame.id) {
|
|
164
|
+
// Server ack for a buffered agent event — remove from buffer
|
|
165
|
+
this.sendBuffer.delete(frame.id);
|
|
166
|
+
const pending = this.pendingAcks.get(frame.id);
|
|
167
|
+
if (pending) {
|
|
168
|
+
this.pendingAcks.delete(frame.id);
|
|
169
|
+
clearTimeout(pending.timer);
|
|
170
|
+
if (frame.ok)
|
|
171
|
+
pending.resolve();
|
|
172
|
+
else
|
|
173
|
+
pending.reject(new Error(frame.error ?? 'Relay event failed'));
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (frame.type === 'req') {
|
|
178
|
+
this.options.onReq?.(frame);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Resend all buffered agent events after reconnect, in insertion order */
|
|
182
|
+
flushSendBuffer() {
|
|
183
|
+
if (this.sendBuffer.size === 0 || !this.ws)
|
|
184
|
+
return;
|
|
185
|
+
for (const event of this.sendBuffer.values()) {
|
|
186
|
+
if (this.state !== 'connected' || !this.ws)
|
|
187
|
+
break;
|
|
188
|
+
this.ws.send(JSON.stringify(event));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
bufferEvent(event) {
|
|
192
|
+
if (!event.id) {
|
|
193
|
+
this.sendEvent(event);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.sendBuffer.set(event.id, event);
|
|
197
|
+
if (this.sendBuffer.size > MAX_SEND_BUFFER) {
|
|
198
|
+
const oldest = this.sendBuffer.keys().next().value;
|
|
199
|
+
if (oldest) {
|
|
200
|
+
this.sendBuffer.delete(oldest);
|
|
201
|
+
const pending = this.pendingAcks.get(oldest);
|
|
202
|
+
if (pending) {
|
|
203
|
+
this.pendingAcks.delete(oldest);
|
|
204
|
+
clearTimeout(pending.timer);
|
|
205
|
+
pending.reject(new Error('Relay send buffer overflow'));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (this.state === 'connected' && this.ws) {
|
|
210
|
+
this.ws.send(JSON.stringify(event));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
clearConnectTimer() {
|
|
214
|
+
if (this.connectTimer !== null) {
|
|
215
|
+
clearTimeout(this.connectTimer);
|
|
216
|
+
this.connectTimer = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
startPing(ws) {
|
|
220
|
+
this.clearPing();
|
|
221
|
+
this.pingTimer = setInterval(() => {
|
|
222
|
+
if (this.ws !== ws || this.state !== 'connected')
|
|
223
|
+
return;
|
|
224
|
+
// Set pong timeout before sending ping
|
|
225
|
+
this.pongTimer = setTimeout(() => {
|
|
226
|
+
// No pong received — connection is dead, force close
|
|
227
|
+
this.closeTrigger = 'pong-timeout';
|
|
228
|
+
ws.close();
|
|
229
|
+
}, PONG_TIMEOUT);
|
|
230
|
+
try {
|
|
231
|
+
ws.ping();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
this.clearPongTimer();
|
|
235
|
+
this.closeTrigger = 'socket-error';
|
|
236
|
+
ws.close();
|
|
237
|
+
}
|
|
238
|
+
}, PING_INTERVAL);
|
|
239
|
+
}
|
|
240
|
+
clearPing() {
|
|
241
|
+
if (this.pingTimer !== null) {
|
|
242
|
+
clearInterval(this.pingTimer);
|
|
243
|
+
this.pingTimer = null;
|
|
244
|
+
}
|
|
245
|
+
this.clearPongTimer();
|
|
246
|
+
}
|
|
247
|
+
clearPongTimer() {
|
|
248
|
+
if (this.pongTimer !== null) {
|
|
249
|
+
clearTimeout(this.pongTimer);
|
|
250
|
+
this.pongTimer = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
startHeartbeat() {
|
|
254
|
+
this.clearHeartbeat();
|
|
255
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
256
|
+
this.closeTrigger = 'heartbeat-timeout';
|
|
257
|
+
this.ws?.close();
|
|
258
|
+
}, HEARTBEAT_TIMEOUT);
|
|
259
|
+
}
|
|
260
|
+
resetHeartbeat() {
|
|
261
|
+
this.startHeartbeat();
|
|
262
|
+
}
|
|
263
|
+
clearHeartbeat() {
|
|
264
|
+
if (this.heartbeatTimer !== null) {
|
|
265
|
+
clearTimeout(this.heartbeatTimer);
|
|
266
|
+
this.heartbeatTimer = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
scheduleReconnect() {
|
|
270
|
+
if (this.disposed)
|
|
271
|
+
return;
|
|
272
|
+
const base = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_DELAY);
|
|
273
|
+
const jitter = base * JITTER_FACTOR * (Math.random() * 2 - 1);
|
|
274
|
+
const delay = Math.round(base + jitter);
|
|
275
|
+
this.reconnectAttempts++;
|
|
276
|
+
this.reconnectTimer = setTimeout(() => {
|
|
277
|
+
this.reconnectTimer = null;
|
|
278
|
+
this.connect();
|
|
279
|
+
}, delay);
|
|
280
|
+
}
|
|
281
|
+
cleanup(rejectAll) {
|
|
282
|
+
this.state = 'disconnected';
|
|
283
|
+
this.closeTrigger = undefined;
|
|
284
|
+
this.lastSocketError = undefined;
|
|
285
|
+
this.clearConnectTimer();
|
|
286
|
+
this.clearHeartbeat();
|
|
287
|
+
this.clearPing();
|
|
288
|
+
if (this.reconnectTimer !== null) {
|
|
289
|
+
clearTimeout(this.reconnectTimer);
|
|
290
|
+
this.reconnectTimer = null;
|
|
291
|
+
}
|
|
292
|
+
if (this.ws) {
|
|
293
|
+
const ws = this.ws;
|
|
294
|
+
this.ws = null;
|
|
295
|
+
ws.removeAllListeners();
|
|
296
|
+
try {
|
|
297
|
+
ws.close();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// already closed
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (rejectAll) {
|
|
304
|
+
for (const [id, pending] of this.pendingAcks) {
|
|
305
|
+
clearTimeout(pending.timer);
|
|
306
|
+
pending.reject(new Error('Relay client disconnected'));
|
|
307
|
+
this.pendingAcks.delete(id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ReqFrame } from '@shennian/wire';
|
|
2
|
+
import type { SessionManagerRuntime } from '../types.js';
|
|
3
|
+
export declare function handleModelsRefresh(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
4
|
+
export declare function handleAgentsRefresh(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @arch docs/architecture/cli/daemon.md#会话管理
|
|
2
|
+
// @test src/__tests__/model-switching.test.ts
|
|
3
|
+
import { SERVERS } from '../../region.js';
|
|
4
|
+
import { loadConfig } from '../../config/index.js';
|
|
5
|
+
import { detectAgents } from '../../agents/detect.js';
|
|
6
|
+
import { refreshAgentInfos } from '../../agents/model-registry.js';
|
|
7
|
+
import { resetCommandSpecCache } from '../../agents/command-spec.js';
|
|
8
|
+
async function refreshMachineAgents(runtime, req) {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const serverUrl = config.serverUrl ?? SERVERS.global.url;
|
|
11
|
+
const detectedAgents = detectAgents();
|
|
12
|
+
const agentList = detectedAgents.map((agent) => agent.type);
|
|
13
|
+
if (detectedAgents.length === 0) {
|
|
14
|
+
runtime.client.sendRes({
|
|
15
|
+
type: 'res',
|
|
16
|
+
id: req.id,
|
|
17
|
+
ok: true,
|
|
18
|
+
payload: { agentList: [], agents: [] },
|
|
19
|
+
});
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const agents = await refreshAgentInfos(detectedAgents, {
|
|
24
|
+
serverUrl,
|
|
25
|
+
authToken: config.machineToken ?? config.accessToken,
|
|
26
|
+
});
|
|
27
|
+
runtime.client.sendRes({
|
|
28
|
+
type: 'res',
|
|
29
|
+
id: req.id,
|
|
30
|
+
ok: true,
|
|
31
|
+
payload: { agentList, agents },
|
|
32
|
+
});
|
|
33
|
+
runtime.client.sendEvent({
|
|
34
|
+
type: 'event',
|
|
35
|
+
event: 'machine.agents',
|
|
36
|
+
payload: { agentList, agents },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
runtime.client.sendRes({
|
|
41
|
+
type: 'res',
|
|
42
|
+
id: req.id,
|
|
43
|
+
ok: false,
|
|
44
|
+
error: err instanceof Error ? err.message : String(err),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function handleModelsRefresh(runtime, req) {
|
|
49
|
+
await refreshMachineAgents(runtime, req);
|
|
50
|
+
}
|
|
51
|
+
export async function handleAgentsRefresh(runtime, req) {
|
|
52
|
+
resetCommandSpecCache();
|
|
53
|
+
runtime.reloadCustomAgents();
|
|
54
|
+
await refreshMachineAgents(runtime, req);
|
|
55
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ReqFrame } from '@shennian/wire';
|
|
2
|
+
import type { SessionManagerRuntime } from '../types.js';
|
|
3
|
+
export declare function handleChatSend(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
4
|
+
export declare function handleChatAbort(runtime: SessionManagerRuntime, req: ReqFrame): Promise<void>;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// @arch docs/architecture/cli/daemon.md#会话管理
|
|
2
|
+
// @test src/__tests__/session-manager.test.ts
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { createAgent } from '../../agents/adapter.js';
|
|
5
|
+
import { reportLog } from '../../log-reporter.js';
|
|
6
|
+
import { lookupClaudeTranscriptCwd } from '../../native-fusion/parsers.js';
|
|
7
|
+
function extractSummary(text) {
|
|
8
|
+
const newline = text.indexOf('\n');
|
|
9
|
+
const end = newline > 0 ? Math.min(newline, 80) : Math.min(text.length, 80);
|
|
10
|
+
return text.slice(0, end);
|
|
11
|
+
}
|
|
12
|
+
function maybeResolveClaudeImportedWorkDir(agentType, workDir, agentSessionId) {
|
|
13
|
+
if (agentType !== 'claude')
|
|
14
|
+
return workDir;
|
|
15
|
+
if (!agentSessionId)
|
|
16
|
+
return workDir;
|
|
17
|
+
const trimmed = workDir.trim();
|
|
18
|
+
const looksLikeTranscriptSlug = !!trimmed && trimmed.startsWith('-') && !trimmed.includes('/');
|
|
19
|
+
if (!looksLikeTranscriptSlug)
|
|
20
|
+
return workDir;
|
|
21
|
+
return lookupClaudeTranscriptCwd(agentSessionId) ?? workDir;
|
|
22
|
+
}
|
|
23
|
+
function bindAdapterEvents(runtime, sessionId, agentType, adapter) {
|
|
24
|
+
let emittedAgentSessionId = null;
|
|
25
|
+
function sendAgentEvent(event, extra = {}) {
|
|
26
|
+
runtime.client.sendAgentEvent({
|
|
27
|
+
type: 'event',
|
|
28
|
+
event: 'agent',
|
|
29
|
+
payload: { ...event, sessionId, ...extra },
|
|
30
|
+
seq: event.seq,
|
|
31
|
+
id: `agent-evt-${event.runId}-${event.seq}`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function flushTextBuffer(activeSession) {
|
|
35
|
+
const textBuffer = activeSession?.pendingTextEvent;
|
|
36
|
+
if (!activeSession || !textBuffer || !textBuffer.text)
|
|
37
|
+
return;
|
|
38
|
+
sendAgentEvent({
|
|
39
|
+
state: 'delta',
|
|
40
|
+
runId: textBuffer.runId,
|
|
41
|
+
seq: textBuffer.seq,
|
|
42
|
+
text: textBuffer.text,
|
|
43
|
+
thinking: textBuffer.thinking || undefined,
|
|
44
|
+
});
|
|
45
|
+
activeSession.pendingTextEvent = null;
|
|
46
|
+
}
|
|
47
|
+
adapter.on('agentEvent', (event) => {
|
|
48
|
+
const activeSession = runtime.sessions.get(sessionId);
|
|
49
|
+
if (activeSession) {
|
|
50
|
+
activeSession.currentRunId = event.runId;
|
|
51
|
+
activeSession.nextEventSeq = event.seq + 1;
|
|
52
|
+
if (event.agentSessionId)
|
|
53
|
+
activeSession.agentSessionId = event.agentSessionId;
|
|
54
|
+
}
|
|
55
|
+
if (event.state !== 'delta') {
|
|
56
|
+
reportLog({
|
|
57
|
+
level: 'info',
|
|
58
|
+
sessionId,
|
|
59
|
+
wsEvent: `agent.${event.state}`,
|
|
60
|
+
wsDirection: 'out',
|
|
61
|
+
metadata: { runId: event.runId, seq: event.seq, agentType },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const runKey = `${sessionId}:${event.runId}`;
|
|
65
|
+
if (event.state === 'delta' && !event.thinking && event.text) {
|
|
66
|
+
runtime.runTextAcc.set(runKey, (runtime.runTextAcc.get(runKey) ?? '') + event.text);
|
|
67
|
+
}
|
|
68
|
+
if (event.agentSessionId && event.agentSessionId !== emittedAgentSessionId) {
|
|
69
|
+
emittedAgentSessionId = event.agentSessionId;
|
|
70
|
+
runtime.nativeFusion?.noteManagedSourceSession(sessionId, agentType, event.agentSessionId);
|
|
71
|
+
runtime.client.sendEvent({
|
|
72
|
+
type: 'event',
|
|
73
|
+
event: 'session.update',
|
|
74
|
+
payload: {
|
|
75
|
+
session: {
|
|
76
|
+
id: sessionId,
|
|
77
|
+
agentType,
|
|
78
|
+
agentSessionId: event.agentSessionId,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (event.state === 'delta') {
|
|
84
|
+
const text = event.text ?? '';
|
|
85
|
+
if (!text)
|
|
86
|
+
return;
|
|
87
|
+
const thinking = Boolean(event.thinking);
|
|
88
|
+
if (activeSession?.pendingTextEvent &&
|
|
89
|
+
(activeSession.pendingTextEvent.runId !== event.runId ||
|
|
90
|
+
activeSession.pendingTextEvent.thinking !== thinking)) {
|
|
91
|
+
flushTextBuffer(activeSession);
|
|
92
|
+
}
|
|
93
|
+
if (activeSession && !activeSession.pendingTextEvent) {
|
|
94
|
+
activeSession.pendingTextEvent = { runId: event.runId, seq: event.seq, text: '', thinking };
|
|
95
|
+
}
|
|
96
|
+
if (activeSession?.pendingTextEvent) {
|
|
97
|
+
activeSession.pendingTextEvent.text += text;
|
|
98
|
+
activeSession.pendingTextEvent.seq = event.seq;
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
let extra = {};
|
|
103
|
+
if (event.state === 'final') {
|
|
104
|
+
flushTextBuffer(activeSession);
|
|
105
|
+
const accumulated = runtime.runTextAcc.get(runKey) ?? '';
|
|
106
|
+
if (accumulated)
|
|
107
|
+
extra = { messageSummary: extractSummary(accumulated) };
|
|
108
|
+
runtime.runTextAcc.delete(runKey);
|
|
109
|
+
}
|
|
110
|
+
else if (event.state === 'error' || event.state === 'aborted') {
|
|
111
|
+
flushTextBuffer(activeSession);
|
|
112
|
+
runtime.runTextAcc.delete(runKey);
|
|
113
|
+
}
|
|
114
|
+
else if (event.state === 'tool-call' || event.state === 'tool-result') {
|
|
115
|
+
flushTextBuffer(activeSession);
|
|
116
|
+
}
|
|
117
|
+
if ((event.state === 'final' || event.state === 'error' || event.state === 'aborted') &&
|
|
118
|
+
activeSession?.currentRunId === event.runId) {
|
|
119
|
+
activeSession.currentRunId = null;
|
|
120
|
+
activeSession.nextEventSeq = 0;
|
|
121
|
+
}
|
|
122
|
+
sendAgentEvent(event, extra);
|
|
123
|
+
});
|
|
124
|
+
adapter.on('error', (error) => {
|
|
125
|
+
runtime.sessions.delete(sessionId);
|
|
126
|
+
runtime.client.sendEvent({
|
|
127
|
+
type: 'event',
|
|
128
|
+
event: 'agent',
|
|
129
|
+
payload: { state: 'error', sessionId, message: error.message, runId: '', seq: 0 },
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function rememberProcessedReqId(runtime, reqId) {
|
|
134
|
+
runtime.processedReqIds.add(reqId);
|
|
135
|
+
if (runtime.processedReqIds.size > 1000) {
|
|
136
|
+
const first = runtime.processedReqIds.values().next().value;
|
|
137
|
+
runtime.processedReqIds.delete(first);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function disposeSession(session) {
|
|
141
|
+
session.adapter.removeAllListeners();
|
|
142
|
+
await session.adapter.stop().catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
async function createActiveSession(runtime, sessionId, agentType, resolvedWorkDir, incomingAgentSid) {
|
|
145
|
+
runtime.evictIdleSessions();
|
|
146
|
+
const adapter = createAgent(agentType);
|
|
147
|
+
if (!adapter)
|
|
148
|
+
throw new Error(`Unsupported agent: ${agentType}`);
|
|
149
|
+
await adapter.start(sessionId, resolvedWorkDir, incomingAgentSid);
|
|
150
|
+
const session = {
|
|
151
|
+
adapter,
|
|
152
|
+
workDir: resolvedWorkDir,
|
|
153
|
+
agentType,
|
|
154
|
+
agentSessionId: incomingAgentSid ?? null,
|
|
155
|
+
lastActiveAt: Date.now(),
|
|
156
|
+
currentRunId: null,
|
|
157
|
+
nextEventSeq: 0,
|
|
158
|
+
pendingTextEvent: null,
|
|
159
|
+
};
|
|
160
|
+
runtime.sessions.set(sessionId, session);
|
|
161
|
+
bindAdapterEvents(runtime, sessionId, agentType, adapter);
|
|
162
|
+
return session;
|
|
163
|
+
}
|
|
164
|
+
function emitSyntheticAbort(runtime, sessionId) {
|
|
165
|
+
const session = runtime.sessions.get(sessionId);
|
|
166
|
+
const runId = session?.currentRunId;
|
|
167
|
+
if (!session || !runId)
|
|
168
|
+
return;
|
|
169
|
+
const seq = session.nextEventSeq;
|
|
170
|
+
runtime.runTextAcc.delete(`${sessionId}:${runId}`);
|
|
171
|
+
session.pendingTextEvent = null;
|
|
172
|
+
session.currentRunId = null;
|
|
173
|
+
session.nextEventSeq = 0;
|
|
174
|
+
runtime.client.sendAgentEvent({
|
|
175
|
+
type: 'event',
|
|
176
|
+
event: 'agent',
|
|
177
|
+
payload: { state: 'aborted', sessionId, runId, seq },
|
|
178
|
+
seq,
|
|
179
|
+
id: `agent-evt-${runId}-${seq}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
export async function handleChatSend(runtime, req) {
|
|
183
|
+
if (runtime.processedReqIds.has(req.id)) {
|
|
184
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: true });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
rememberProcessedReqId(runtime, req.id);
|
|
188
|
+
const { sessionId, text, agentType, workDir, agentSessionId: incomingAgentSid, modelId, clientMessageId } = req.params;
|
|
189
|
+
if (!sessionId || !text) {
|
|
190
|
+
runtime.processedReqIds.delete(req.id);
|
|
191
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: false, error: 'sessionId and text are required' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const requestedAgentType = agentType;
|
|
195
|
+
const resolvedWorkDir = runtime.resolvePath(maybeResolveClaudeImportedWorkDir(requestedAgentType, workDir || os.homedir(), incomingAgentSid) || os.homedir());
|
|
196
|
+
let session = runtime.sessions.get(sessionId);
|
|
197
|
+
if (session) {
|
|
198
|
+
session.lastActiveAt = Date.now();
|
|
199
|
+
const sessionDrifted = session.agentType !== requestedAgentType ||
|
|
200
|
+
session.workDir !== resolvedWorkDir;
|
|
201
|
+
if (sessionDrifted) {
|
|
202
|
+
runtime.sessions.delete(sessionId);
|
|
203
|
+
try {
|
|
204
|
+
await disposeSession(session);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
runtime.processedReqIds.delete(req.id);
|
|
208
|
+
}
|
|
209
|
+
session = undefined;
|
|
210
|
+
}
|
|
211
|
+
else if (incomingAgentSid && session.agentSessionId !== incomingAgentSid) {
|
|
212
|
+
try {
|
|
213
|
+
await session.adapter.resume(incomingAgentSid);
|
|
214
|
+
session.agentSessionId = incomingAgentSid;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
runtime.sessions.delete(sessionId);
|
|
218
|
+
try {
|
|
219
|
+
await disposeSession(session);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
runtime.processedReqIds.delete(req.id);
|
|
223
|
+
}
|
|
224
|
+
session = undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!session) {
|
|
229
|
+
try {
|
|
230
|
+
session = await createActiveSession(runtime, sessionId, requestedAgentType, resolvedWorkDir, incomingAgentSid);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
runtime.client.sendEvent({
|
|
234
|
+
type: 'event',
|
|
235
|
+
event: 'agent',
|
|
236
|
+
payload: {
|
|
237
|
+
state: 'error',
|
|
238
|
+
sessionId,
|
|
239
|
+
message: err instanceof Error && err.message.startsWith('Unsupported agent:')
|
|
240
|
+
? err.message
|
|
241
|
+
: `Failed to start ${agentType}: ${err instanceof Error ? err.message : String(err)}`,
|
|
242
|
+
runId: '',
|
|
243
|
+
seq: 0,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
runtime.processedReqIds.delete(req.id);
|
|
247
|
+
runtime.client.sendRes({
|
|
248
|
+
type: 'res',
|
|
249
|
+
id: req.id,
|
|
250
|
+
ok: false,
|
|
251
|
+
error: err instanceof Error && err.message.startsWith('Unsupported agent:')
|
|
252
|
+
? err.message
|
|
253
|
+
: `Failed to start ${agentType}: ${err instanceof Error ? err.message : String(err)}`,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
session.currentRunId = null;
|
|
260
|
+
session.nextEventSeq = 0;
|
|
261
|
+
runtime.nativeFusion?.registerManagedSend({
|
|
262
|
+
sessionId,
|
|
263
|
+
agentType: requestedAgentType,
|
|
264
|
+
canonicalMessageId: clientMessageId ?? null,
|
|
265
|
+
sourceSessionKey: session.agentSessionId ?? incomingAgentSid ?? null,
|
|
266
|
+
text,
|
|
267
|
+
});
|
|
268
|
+
reportLog({
|
|
269
|
+
level: 'info',
|
|
270
|
+
sessionId,
|
|
271
|
+
wsEvent: 'chat.send.start',
|
|
272
|
+
metadata: { reqId: req.id, agentType: requestedAgentType, modelId },
|
|
273
|
+
});
|
|
274
|
+
await session.adapter.send(text, modelId);
|
|
275
|
+
reportLog({
|
|
276
|
+
level: 'info',
|
|
277
|
+
sessionId,
|
|
278
|
+
wsEvent: 'chat.send.done',
|
|
279
|
+
metadata: { reqId: req.id },
|
|
280
|
+
});
|
|
281
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: true });
|
|
282
|
+
reportLog({
|
|
283
|
+
level: 'info',
|
|
284
|
+
sessionId,
|
|
285
|
+
wsEvent: 'chat.send.res',
|
|
286
|
+
metadata: { reqId: req.id, ok: true },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
runtime.sessions.delete(sessionId);
|
|
291
|
+
try {
|
|
292
|
+
await disposeSession(session);
|
|
293
|
+
}
|
|
294
|
+
catch { /* best-effort cleanup */ }
|
|
295
|
+
runtime.processedReqIds.delete(req.id);
|
|
296
|
+
runtime.client.sendEvent({
|
|
297
|
+
type: 'event',
|
|
298
|
+
event: 'agent',
|
|
299
|
+
payload: {
|
|
300
|
+
state: 'error',
|
|
301
|
+
sessionId,
|
|
302
|
+
message: `Agent send failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
303
|
+
runId: '',
|
|
304
|
+
seq: 0,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
runtime.client.sendRes({
|
|
308
|
+
type: 'res',
|
|
309
|
+
id: req.id,
|
|
310
|
+
ok: false,
|
|
311
|
+
error: `Agent send failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
export async function handleChatAbort(runtime, req) {
|
|
316
|
+
const { sessionId } = req.params;
|
|
317
|
+
const session = runtime.sessions.get(sessionId);
|
|
318
|
+
if (session) {
|
|
319
|
+
try {
|
|
320
|
+
await session.adapter.stop();
|
|
321
|
+
}
|
|
322
|
+
catch { /* best-effort: still emit synthetic abort below */ }
|
|
323
|
+
emitSyntheticAbort(runtime, sessionId);
|
|
324
|
+
}
|
|
325
|
+
runtime.client.sendRes({ type: 'res', id: req.id, ok: true });
|
|
326
|
+
}
|