linco-connect 1.0.0
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 +426 -0
- package/bin/linco.js +465 -0
- package/package.json +25 -0
- package/public/index.html +1457 -0
- package/server.js +17 -0
- package/src/agentRunner.js +37 -0
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +869 -0
- package/src/attachmentHandler.js +258 -0
- package/src/claudeRunner.js +564 -0
- package/src/config.js +371 -0
- package/src/danger.js +21 -0
- package/src/httpStatic.js +166 -0
- package/src/imConnector.js +488 -0
- package/src/imageHandler.js +38 -0
- package/src/lincoProtocol.js +209 -0
- package/src/localAuth.js +46 -0
- package/src/logger.js +137 -0
- package/src/outgoingAttachmentHandler.js +204 -0
- package/src/protocol.js +17 -0
- package/src/serverApp.js +61 -0
- package/src/session.js +359 -0
- package/src/slashCommands.js +349 -0
- package/src/streamBuffer.js +73 -0
- package/src/wsServer.js +293 -0
package/src/session.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { ensureDir } = require('./config');
|
|
5
|
+
const { stopOutboxWatcher } = require('./outgoingAttachmentHandler');
|
|
6
|
+
const { createTextStreamBuffer, resetTextStream } = require('./streamBuffer');
|
|
7
|
+
|
|
8
|
+
const MAX_EXTERNAL_SESSION_ID_LENGTH = 256;
|
|
9
|
+
const SESSION_METADATA_FILE = 'session.json';
|
|
10
|
+
|
|
11
|
+
function createStreamState() {
|
|
12
|
+
return createTextStreamBuffer();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createSession(config, { externalSessionId, agentType = 'claude' } = {}) {
|
|
16
|
+
const normalizedAgentType = normalizeAgentType(agentType);
|
|
17
|
+
const normalizedExternalId = normalizeExternalSessionId(externalSessionId);
|
|
18
|
+
const id = normalizedExternalId || createFallbackSessionId();
|
|
19
|
+
const idSource = normalizedExternalId ? 'im' : 'generated';
|
|
20
|
+
const storageId = deriveStorageId(id);
|
|
21
|
+
const runtimeDir = resolveSessionRuntimeDir(config, normalizedAgentType, storageId);
|
|
22
|
+
const defaultWorkspace = path.join(runtimeDir, 'workspace');
|
|
23
|
+
const attachmentsDir = resolveRuntimeSubdir(runtimeDir, config.attachmentsDirName || 'attachments');
|
|
24
|
+
const outboxDir = resolveRuntimeSubdir(runtimeDir, config.outboxDirName || 'outbox');
|
|
25
|
+
|
|
26
|
+
ensureDir(runtimeDir);
|
|
27
|
+
ensureDir(defaultWorkspace);
|
|
28
|
+
ensureDir(attachmentsDir);
|
|
29
|
+
ensureDir(outboxDir);
|
|
30
|
+
|
|
31
|
+
const metadata = loadSessionMetadata(runtimeDir);
|
|
32
|
+
const legacyMetadata = normalizedAgentType === 'claude' ? loadSessionMetadata(path.join(config.sessionsDir, storageId)) : {};
|
|
33
|
+
const workspace = resolveSavedWorkspace(metadata.workspace || legacyMetadata.workspace, defaultWorkspace);
|
|
34
|
+
const agentSessionId = metadata.agentSessionId || metadata.claudeSessionId || legacyMetadata.agentSessionId || legacyMetadata.claudeSessionId || null;
|
|
35
|
+
const agentSessionHistory = metadata.agentSessionHistory || [];
|
|
36
|
+
|
|
37
|
+
const activeEntry = agentSessionHistory.find(e => e.id === agentSessionId);
|
|
38
|
+
const messageCount = activeEntry?.messageCount ?? 0;
|
|
39
|
+
const usage = activeEntry ? { ...activeEntry.usage } : { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
idSource,
|
|
44
|
+
agentType: normalizedAgentType,
|
|
45
|
+
activeKey: activeSessionKey(normalizedAgentType, id),
|
|
46
|
+
storageId,
|
|
47
|
+
runtimeDir,
|
|
48
|
+
workspace,
|
|
49
|
+
attachmentsDir,
|
|
50
|
+
outboxDir,
|
|
51
|
+
agentSessionId,
|
|
52
|
+
claudeSessionId: agentSessionId,
|
|
53
|
+
agentSessionHistory,
|
|
54
|
+
messageCount,
|
|
55
|
+
usage,
|
|
56
|
+
agentProcess: null,
|
|
57
|
+
claudeProcess: null,
|
|
58
|
+
stdoutBuffer: '',
|
|
59
|
+
isTurnActive: false,
|
|
60
|
+
currentInputForNoOutput: null,
|
|
61
|
+
messageQueue: [],
|
|
62
|
+
pendingDanger: null,
|
|
63
|
+
pendingPermission: null,
|
|
64
|
+
streamState: createStreamState(),
|
|
65
|
+
sawPartialAssistantText: false,
|
|
66
|
+
outgoingAttachments: new Map(),
|
|
67
|
+
outboxSeen: new Set(),
|
|
68
|
+
outboxTimer: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeExternalSessionId(value) {
|
|
73
|
+
if (value == null) return '';
|
|
74
|
+
const id = String(value).trim();
|
|
75
|
+
if (!id) return '';
|
|
76
|
+
if (id.length > MAX_EXTERNAL_SESSION_ID_LENGTH) {
|
|
77
|
+
throw new Error(`session_id 长度不能超过 ${MAX_EXTERNAL_SESSION_ID_LENGTH}`);
|
|
78
|
+
}
|
|
79
|
+
if (/[\x00-\x1F\x7F]/.test(id)) {
|
|
80
|
+
throw new Error('session_id 不能包含控制字符');
|
|
81
|
+
}
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createFallbackSessionId() {
|
|
86
|
+
return crypto.randomUUID().slice(0, 8);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deriveStorageId(sessionId) {
|
|
90
|
+
return `sid_${crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeAgentType(value) {
|
|
94
|
+
const type = String(value || 'claude').trim().toLowerCase();
|
|
95
|
+
if (!/^[a-z0-9_-]+$/.test(type)) {
|
|
96
|
+
throw new Error('agentType 只能包含字母、数字、下划线或中划线');
|
|
97
|
+
}
|
|
98
|
+
return type;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function activeSessionKey(agentType, sessionId) {
|
|
102
|
+
return `${normalizeAgentType(agentType)}:${sessionId}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveSessionRuntimeDir(config, agentType, storageId) {
|
|
106
|
+
return path.join(config.lincoHome, normalizeAgentType(agentType), 'sessions', storageId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveRuntimeSubdir(runtimeDir, dirName) {
|
|
110
|
+
const name = String(dirName || '').trim();
|
|
111
|
+
if (!name || path.isAbsolute(name)) {
|
|
112
|
+
throw new Error('运行目录子路径必须是相对路径');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const resolved = path.resolve(runtimeDir, name);
|
|
116
|
+
if (!isInsideOrSame(resolved, runtimeDir)) {
|
|
117
|
+
throw new Error('运行目录子路径不能跳出当前会话目录');
|
|
118
|
+
}
|
|
119
|
+
return resolved;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isInsideOrSame(filePath, dir) {
|
|
123
|
+
const relative = path.relative(dir, filePath);
|
|
124
|
+
return !relative || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveSavedWorkspace(savedWorkspace, defaultWorkspace) {
|
|
128
|
+
if (!savedWorkspace || typeof savedWorkspace !== 'string') return defaultWorkspace;
|
|
129
|
+
if (!path.isAbsolute(savedWorkspace)) return defaultWorkspace;
|
|
130
|
+
if (!fs.existsSync(savedWorkspace)) return defaultWorkspace;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
return fs.statSync(savedWorkspace).isDirectory() ? savedWorkspace : defaultWorkspace;
|
|
134
|
+
} catch {
|
|
135
|
+
return defaultWorkspace;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function clearStreamState(session) {
|
|
140
|
+
resetTextStream(session.streamState);
|
|
141
|
+
session.streamState = createStreamState();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sessionMetadataPath(sessionOrRuntimeDir) {
|
|
145
|
+
const runtimeDir = typeof sessionOrRuntimeDir === 'string'
|
|
146
|
+
? sessionOrRuntimeDir
|
|
147
|
+
: sessionOrRuntimeDir?.runtimeDir;
|
|
148
|
+
return path.join(runtimeDir, SESSION_METADATA_FILE);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function loadSessionMetadata(runtimeDir) {
|
|
152
|
+
const file = sessionMetadataPath(runtimeDir);
|
|
153
|
+
if (!fs.existsSync(file)) return {};
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const metadata = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
157
|
+
return metadata && typeof metadata === 'object' ? metadata : {};
|
|
158
|
+
} catch {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function saveSessionMetadata(session) {
|
|
164
|
+
const metadata = {
|
|
165
|
+
sessionId: session.id,
|
|
166
|
+
storageId: session.storageId,
|
|
167
|
+
workspace: session.workspace,
|
|
168
|
+
agentType: session.agentType || 'claude',
|
|
169
|
+
agentSessionId: session.agentSessionId || session.claudeSessionId || null,
|
|
170
|
+
claudeSessionId: session.agentType === 'claude' ? (session.agentSessionId || session.claudeSessionId || null) : null,
|
|
171
|
+
agentSessionHistory: session.agentSessionHistory || [],
|
|
172
|
+
updatedAt: new Date().toISOString(),
|
|
173
|
+
};
|
|
174
|
+
fs.writeFileSync(sessionMetadataPath(session), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createAgentSessionEntry(session, id, firstMessage = '') {
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
agentType: session.agentType || 'claude',
|
|
181
|
+
createdAt: new Date().toISOString(),
|
|
182
|
+
lastActiveAt: new Date().toISOString(),
|
|
183
|
+
workspace: session.workspace,
|
|
184
|
+
firstMessage: String(firstMessage || '').slice(0, 200),
|
|
185
|
+
messageCount: 0,
|
|
186
|
+
usage: {
|
|
187
|
+
inputTokens: 0,
|
|
188
|
+
outputTokens: 0,
|
|
189
|
+
cacheReadTokens: 0,
|
|
190
|
+
cacheCreationTokens: 0,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function recordAgentSession(session, firstMessage = '') {
|
|
196
|
+
if (!session.agentSessionHistory) session.agentSessionHistory = [];
|
|
197
|
+
|
|
198
|
+
const existing = session.agentSessionHistory.find(e => e.id === session.agentSessionId);
|
|
199
|
+
if (existing) {
|
|
200
|
+
existing.lastActiveAt = new Date().toISOString();
|
|
201
|
+
existing.isActive = true;
|
|
202
|
+
saveSessionMetadata(session);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (session.agentSessionId) {
|
|
207
|
+
for (const entry of session.agentSessionHistory) {
|
|
208
|
+
entry.isActive = false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entry = createAgentSessionEntry(session, session.agentSessionId, firstMessage);
|
|
212
|
+
entry.isActive = true;
|
|
213
|
+
session.agentSessionHistory.push(entry);
|
|
214
|
+
saveSessionMetadata(session);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function updateAgentSessionHistory(session) {
|
|
219
|
+
if (!session.agentSessionHistory || !session.agentSessionId) return;
|
|
220
|
+
|
|
221
|
+
const entry = session.agentSessionHistory.find(e => e.id === session.agentSessionId);
|
|
222
|
+
if (entry) {
|
|
223
|
+
entry.lastActiveAt = new Date().toISOString();
|
|
224
|
+
if (session.messageCount != null) entry.messageCount = session.messageCount;
|
|
225
|
+
if (session.usage) {
|
|
226
|
+
entry.usage = { ...entry.usage, ...session.usage };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
saveSessionMetadata(session);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function removeAgentSessionFromHistory(session, targetId) {
|
|
233
|
+
if (!session.agentSessionHistory) return false;
|
|
234
|
+
|
|
235
|
+
const idx = session.agentSessionHistory.findIndex(e => e.id === targetId);
|
|
236
|
+
if (idx === -1) return false;
|
|
237
|
+
|
|
238
|
+
const removed = session.agentSessionHistory[idx];
|
|
239
|
+
session.agentSessionHistory.splice(idx, 1);
|
|
240
|
+
|
|
241
|
+
if (removed.isActive || session.agentSessionId === targetId) {
|
|
242
|
+
session.agentSessionId = null;
|
|
243
|
+
session.claudeSessionId = null;
|
|
244
|
+
session.messageCount = 0;
|
|
245
|
+
session.usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
saveSessionMetadata(session);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
function extractText(input) {
|
|
252
|
+
if (!Array.isArray(input)) return String(input || '');
|
|
253
|
+
return input
|
|
254
|
+
.filter(block => block?.type === 'text')
|
|
255
|
+
.map(block => block.text || '')
|
|
256
|
+
.join('\n');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function persistAgentSessionId(session, agentSessionId) {
|
|
260
|
+
const nextId = String(agentSessionId || '').trim();
|
|
261
|
+
if (!nextId || session.agentSessionId === nextId) return;
|
|
262
|
+
const isNew = !session.agentSessionId;
|
|
263
|
+
session.agentSessionId = nextId;
|
|
264
|
+
if (session.agentType === 'claude') session.claudeSessionId = nextId;
|
|
265
|
+
if (isNew) {
|
|
266
|
+
recordAgentSession(session, extractText(session.currentInputForNoOutput));
|
|
267
|
+
} else {
|
|
268
|
+
saveSessionMetadata(session);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function clearPersistedAgentSession(session) {
|
|
273
|
+
session.agentSessionId = null;
|
|
274
|
+
session.claudeSessionId = null;
|
|
275
|
+
saveSessionMetadata(session);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function persistClaudeSessionId(session, claudeSessionId) {
|
|
279
|
+
persistAgentSessionId(session, claudeSessionId);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function clearPersistedClaudeSession(session) {
|
|
283
|
+
clearPersistedAgentSession(session);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resetConversationState(session, { clearAgentSession = true, clearClaudeSession } = {}) {
|
|
287
|
+
const shouldClearAgentSession = clearClaudeSession == null ? clearAgentSession : clearClaudeSession;
|
|
288
|
+
if (shouldClearAgentSession) {
|
|
289
|
+
clearPersistedAgentSession(session);
|
|
290
|
+
}
|
|
291
|
+
session.stdoutBuffer = '';
|
|
292
|
+
session.isTurnActive = false;
|
|
293
|
+
session.currentInputForNoOutput = null;
|
|
294
|
+
session.messageQueue = [];
|
|
295
|
+
session.pendingDanger = null;
|
|
296
|
+
session.pendingPermission = null;
|
|
297
|
+
session.sawPartialAssistantText = false;
|
|
298
|
+
clearStreamState(session);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function stopAgentProcess(session, { clearAgentSession = false, clearClaudeSession } = {}) {
|
|
302
|
+
const shouldClearAgentSession = clearClaudeSession == null ? clearAgentSession : clearClaudeSession;
|
|
303
|
+
const child = session.agentProcess || session.claudeProcess;
|
|
304
|
+
session.agentProcess = null;
|
|
305
|
+
session.claudeProcess = null;
|
|
306
|
+
|
|
307
|
+
if (child) {
|
|
308
|
+
try {
|
|
309
|
+
if (!child.stdin.destroyed) child.stdin.end();
|
|
310
|
+
} catch {}
|
|
311
|
+
|
|
312
|
+
if (!child.killed && child.exitCode === null) {
|
|
313
|
+
setTimeout(() => {
|
|
314
|
+
if (!child.killed && child.exitCode === null) {
|
|
315
|
+
child.kill();
|
|
316
|
+
}
|
|
317
|
+
}, 3000).unref?.();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
resetConversationState(session, { clearAgentSession: shouldClearAgentSession });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function stopClaudeProcess(session, { clearClaudeSession = false } = {}) {
|
|
325
|
+
stopAgentProcess(session, { clearAgentSession: clearClaudeSession });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function killCurrentProcess(session) {
|
|
329
|
+
stopAgentProcess(session, { clearAgentSession: false });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function cleanupSession(session) {
|
|
333
|
+
stopOutboxWatcher(session);
|
|
334
|
+
stopAgentProcess(session, { clearAgentSession: false });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
activeSessionKey,
|
|
339
|
+
cleanupSession,
|
|
340
|
+
clearPersistedAgentSession,
|
|
341
|
+
clearPersistedClaudeSession,
|
|
342
|
+
clearStreamState,
|
|
343
|
+
createAgentSessionEntry,
|
|
344
|
+
createSession,
|
|
345
|
+
deriveStorageId,
|
|
346
|
+
killCurrentProcess,
|
|
347
|
+
loadSessionMetadata,
|
|
348
|
+
normalizeAgentType,
|
|
349
|
+
normalizeExternalSessionId,
|
|
350
|
+
persistAgentSessionId,
|
|
351
|
+
persistClaudeSessionId,
|
|
352
|
+
recordAgentSession,
|
|
353
|
+
removeAgentSessionFromHistory,
|
|
354
|
+
resetConversationState,
|
|
355
|
+
saveSessionMetadata,
|
|
356
|
+
stopAgentProcess,
|
|
357
|
+
stopClaudeProcess,
|
|
358
|
+
updateAgentSessionHistory,
|
|
359
|
+
};
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { resetOutgoingAttachments, startOutboxWatcher, stopOutboxWatcher } = require('./outgoingAttachmentHandler');
|
|
4
|
+
const { sendError, sendSystem } = require('./protocol');
|
|
5
|
+
const { removeAgentSessionFromHistory, saveSessionMetadata, stopAgentProcess } = require('./session');
|
|
6
|
+
|
|
7
|
+
function localCommandsHelp() {
|
|
8
|
+
return `📋 Linco 本地命令:
|
|
9
|
+
/help - 显示快速帮助
|
|
10
|
+
/commands - 显示命令说明
|
|
11
|
+
/status - 显示当前会话状态
|
|
12
|
+
/pwd - 显示当前工作目录
|
|
13
|
+
/cd <路径> - 切换工作目录并开启新 Agent 会话
|
|
14
|
+
/cd - 列出当前目录内容
|
|
15
|
+
/new - 开启新 Agent 会话(清除上下文)
|
|
16
|
+
/stop - 停止当前 Agent 进程,保留可恢复会话 ID
|
|
17
|
+
/base - 显示 Linco 运行目录信息
|
|
18
|
+
/list [条数] - 列出当前 IM 会话下最近的 Agent Session 历史(默认 10 条)
|
|
19
|
+
/switch <序号> - 切换到指定 Agent Session(恢复上下文)
|
|
20
|
+
/delete <序号> - 从历史记录中删除指定 Agent Session
|
|
21
|
+
/usage - 显示 Token 用量统计`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handleSlashCommand(text, ws, session, config) {
|
|
25
|
+
const parts = text.trim().split(/\s+/);
|
|
26
|
+
const cmd = parts[0].toLowerCase();
|
|
27
|
+
|
|
28
|
+
switch (cmd) {
|
|
29
|
+
case '/help':
|
|
30
|
+
sendSystem(ws, `${localCommandsHelp()}
|
|
31
|
+
|
|
32
|
+
📎 支持附件:默认允许普通文件(如 csv、xlsx、sql、txt、md、pdf、docx 等),高风险可执行/脚本文件默认拦截
|
|
33
|
+
📤 Agent 生成文件后可放入当前会话 outbox 目录自动发送到对话框
|
|
34
|
+
🔁 其他 /xxx 命令会透传给当前 Agent,优先使用当前 Agent 原生斜杠命令能力`);
|
|
35
|
+
return true;
|
|
36
|
+
|
|
37
|
+
case '/commands':
|
|
38
|
+
sendSystem(ws, `${localCommandsHelp()}
|
|
39
|
+
|
|
40
|
+
🔁 Agent 原生命令:
|
|
41
|
+
除上述本地命令外,其他 /xxx 会直接发送给当前 Agent,例如 /model、/status、/memory、/compact 等。
|
|
42
|
+
|
|
43
|
+
⚠️ 注意:部分 Agent 原生命令只在交互式 CLI/TUI 中更新界面;在 Linco 的 stream-json 模式下可能返回“无输出”或提示当前环境不可用。遇到这种情况不是连接失败,而是该命令不适合当前桥接模式。`);
|
|
44
|
+
return true;
|
|
45
|
+
|
|
46
|
+
case '/status':
|
|
47
|
+
sendStatus(ws, session);
|
|
48
|
+
return true;
|
|
49
|
+
|
|
50
|
+
case '/pwd':
|
|
51
|
+
sendSystem(ws, `📂 ${session.workspace}`);
|
|
52
|
+
return true;
|
|
53
|
+
|
|
54
|
+
case '/cd':
|
|
55
|
+
handleCd(parts[1], ws, session, config);
|
|
56
|
+
return true;
|
|
57
|
+
|
|
58
|
+
case '/new':
|
|
59
|
+
stopAgentProcess(session, { clearAgentSession: true });
|
|
60
|
+
resetOutgoingAttachments(session, config);
|
|
61
|
+
sendSystem(ws, '🆕 已开启新会话,之前上下文已清除。');
|
|
62
|
+
return true;
|
|
63
|
+
|
|
64
|
+
case '/stop':
|
|
65
|
+
stopAgentProcess(session, { clearAgentSession: false });
|
|
66
|
+
sendSystem(ws, '⏹️ 已停止当前 Agent 进程,下次消息会尝试恢复当前会话。');
|
|
67
|
+
return true;
|
|
68
|
+
|
|
69
|
+
case '/base':
|
|
70
|
+
sendSystem(ws, `🗄️ Linco 运行信息:
|
|
71
|
+
当前工作目录: ${session.workspace}
|
|
72
|
+
Linco Home: ${config.lincoHome}
|
|
73
|
+
会话运行目录: ${session.runtimeDir}
|
|
74
|
+
附件目录: ${session.attachmentsDir}
|
|
75
|
+
outbox 目录: ${session.outboxDir}`);
|
|
76
|
+
return true;
|
|
77
|
+
|
|
78
|
+
case '/list':
|
|
79
|
+
handleList(parts[1], ws, session);
|
|
80
|
+
return true;
|
|
81
|
+
|
|
82
|
+
case '/switch':
|
|
83
|
+
handleSwitch(parts[1], ws, session, config);
|
|
84
|
+
return true;
|
|
85
|
+
|
|
86
|
+
case '/delete':
|
|
87
|
+
handleDelete(parts[1], ws, session);
|
|
88
|
+
return true;
|
|
89
|
+
|
|
90
|
+
case '/usage':
|
|
91
|
+
handleUsage(ws, session);
|
|
92
|
+
return true;
|
|
93
|
+
|
|
94
|
+
default:
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sendStatus(ws, session) {
|
|
100
|
+
const processRunning = !!(
|
|
101
|
+
(session.agentProcess || session.claudeProcess) &&
|
|
102
|
+
(session.agentProcess || session.claudeProcess).exitCode === null &&
|
|
103
|
+
!(session.agentProcess || session.claudeProcess).killed
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const historyCount = session.agentSessionHistory?.length || 0;
|
|
107
|
+
const activeEntry = session.agentSessionHistory?.find(e => e.isActive);
|
|
108
|
+
|
|
109
|
+
sendSystem(ws, `📊 当前会话状态:
|
|
110
|
+
工作目录: ${session.workspace}
|
|
111
|
+
会话 ID: ${session.id}
|
|
112
|
+
存储 ID: ${session.storageId}
|
|
113
|
+
Agent 类型: ${session.agentType || 'claude'}
|
|
114
|
+
Agent session: ${session.agentSessionId || session.claudeSessionId || '(尚未建立)'}
|
|
115
|
+
活跃历史条目: ${activeEntry ? `"${activeEntry.firstMessage?.slice(0, 40) || '(无)'}" (${activeEntry.id})` : '无'}
|
|
116
|
+
历史总数: ${historyCount}
|
|
117
|
+
Agent 进程: ${processRunning ? '运行中' : '未运行'}
|
|
118
|
+
当前 turn: ${session.isTurnActive ? '进行中' : '空闲'}
|
|
119
|
+
排队消息: ${session.messageQueue.length}
|
|
120
|
+
待确认: ${session.pendingPermission ? '工具权限' : session.pendingDanger ? '危险操作' : '无'}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleCd(targetPath, ws, session, config) {
|
|
124
|
+
if (!targetPath) {
|
|
125
|
+
try {
|
|
126
|
+
const files = fs.readdirSync(session.workspace);
|
|
127
|
+
const fileList = files.length > 0
|
|
128
|
+
? files.map(file => {
|
|
129
|
+
const fullPath = path.join(session.workspace, file);
|
|
130
|
+
const isDir = fs.statSync(fullPath).isDirectory();
|
|
131
|
+
return isDir ? `📁 ${file}/` : `📄 ${file}`;
|
|
132
|
+
}).join('\n')
|
|
133
|
+
: '(空目录)';
|
|
134
|
+
sendSystem(ws, `📂 ${session.workspace}\n\n${fileList}`);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
sendError(ws, `❌ 无法列出目录: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const newPath = path.isAbsolute(targetPath)
|
|
142
|
+
? targetPath
|
|
143
|
+
: path.resolve(session.workspace, targetPath);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (!fs.existsSync(newPath)) {
|
|
147
|
+
sendError(ws, `❌ 目录不存在: ${newPath}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const stat = fs.statSync(newPath);
|
|
152
|
+
if (!stat.isDirectory()) {
|
|
153
|
+
sendError(ws, `❌ 不是目录: ${newPath}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
stopAgentProcess(session, { clearAgentSession: true });
|
|
158
|
+
stopOutboxWatcher(session);
|
|
159
|
+
session.workspace = newPath;
|
|
160
|
+
saveSessionMetadata(session);
|
|
161
|
+
resetOutgoingAttachments(session);
|
|
162
|
+
startOutboxWatcher(ws, session, config);
|
|
163
|
+
sendSystem(ws, `📂 工作目录已切换至: ${newPath}\n🆕 已开启新 Agent 会话。`);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
sendError(ws, `❌ 切换目录失败: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatTokenCount(n) {
|
|
170
|
+
if (!n) return '0';
|
|
171
|
+
return n.toLocaleString();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatTimeShort(isoString) {
|
|
175
|
+
if (!isoString) return '-';
|
|
176
|
+
const d = new Date(isoString);
|
|
177
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
178
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
179
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
180
|
+
const mi = String(d.getMinutes()).padStart(2, '0');
|
|
181
|
+
return `${mm}-${dd} ${hh}:${mi}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function findEntryByIndexOrId(indexOrId, history) {
|
|
185
|
+
const idx = parseInt(indexOrId, 10);
|
|
186
|
+
if (!isNaN(idx) && idx >= 1 && idx <= history.length) {
|
|
187
|
+
return { entry: history[idx - 1], index: idx };
|
|
188
|
+
}
|
|
189
|
+
const entry = history.find(e => e.id === indexOrId);
|
|
190
|
+
if (entry) return { entry, index: history.indexOf(entry) + 1 };
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleList(limitArg, ws, session) {
|
|
195
|
+
const history = session.agentSessionHistory;
|
|
196
|
+
if (!history || history.length === 0) {
|
|
197
|
+
sendSystem(ws, '📋 当前 IM 会话下尚无 Agent Session 记录。');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const limit = limitArg === undefined ? 10 : parseInt(limitArg, 10);
|
|
202
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
203
|
+
sendError(ws, '❌ /list 参数必须是大于 0 的数字,例如 /list 20。');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const start = Math.max(history.length - limit, 0);
|
|
208
|
+
const visibleHistory = history.slice(start);
|
|
209
|
+
const lines = visibleHistory.map((entry, i) => {
|
|
210
|
+
const num = start + i + 1;
|
|
211
|
+
const summary = entry.firstMessage ? entry.firstMessage.slice(0, 40) : '(新会话)';
|
|
212
|
+
const msgs = entry.messageCount || 0;
|
|
213
|
+
const inTokens = formatTokenCount(entry.usage?.inputTokens);
|
|
214
|
+
const outTokens = formatTokenCount(entry.usage?.outputTokens);
|
|
215
|
+
const time = formatTimeShort(entry.lastActiveAt || entry.createdAt);
|
|
216
|
+
const marker = entry.isActive ? '📌' : ' ';
|
|
217
|
+
const showUsage = (entry.agentType || session.agentType || 'claude') !== 'codex';
|
|
218
|
+
const usageText = showUsage ? ` · ${inTokens} in / ${outTokens} out` : '';
|
|
219
|
+
return `${marker} ${num}. ${summary} · ${msgs} msgs${usageText} · ${time}`;
|
|
220
|
+
}).join('\n');
|
|
221
|
+
|
|
222
|
+
sendSystem(ws, `📋 当前 IM 会话的 Agent Session 历史(最近 ${visibleHistory.length} / 共 ${history.length} 条):\n\n${lines}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function handleSwitch(indexOrId, ws, session, config) {
|
|
226
|
+
const history = session.agentSessionHistory;
|
|
227
|
+
if (!history || history.length === 0) {
|
|
228
|
+
sendError(ws, '❌ 当前 IM 会话下没有 Agent Session 记录,请先发送消息或执行 /new。');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!indexOrId) {
|
|
233
|
+
sendError(ws, '❌ 请指定要切换的 Session 序号或 ID。使用 /list 查看可用 Session。');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result = findEntryByIndexOrId(indexOrId, history);
|
|
238
|
+
if (!result) {
|
|
239
|
+
sendError(ws, `❌ 未找到匹配的 Session,序号范围: 1-${history.length}。`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { entry, index } = result;
|
|
244
|
+
|
|
245
|
+
for (const e of history) e.isActive = false;
|
|
246
|
+
entry.isActive = true;
|
|
247
|
+
|
|
248
|
+
session.agentSessionId = entry.id;
|
|
249
|
+
if (session.agentType === 'claude') session.claudeSessionId = entry.id;
|
|
250
|
+
session.messageCount = entry.messageCount || 0;
|
|
251
|
+
session.usage = entry.usage ? { ...entry.usage } : { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
252
|
+
saveSessionMetadata(session);
|
|
253
|
+
|
|
254
|
+
const child = session.agentProcess || session.claudeProcess;
|
|
255
|
+
if (child && !child.killed && child.exitCode === null) {
|
|
256
|
+
stopAgentProcess(session, { clearAgentSession: false });
|
|
257
|
+
} else {
|
|
258
|
+
session.isTurnActive = false;
|
|
259
|
+
session.currentInputForNoOutput = null;
|
|
260
|
+
session.messageQueue = [];
|
|
261
|
+
session.pendingDanger = null;
|
|
262
|
+
session.pendingPermission = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Verify the change persisted correctly
|
|
266
|
+
config.logger?.info('switch: verification', {
|
|
267
|
+
sessionId: session.id,
|
|
268
|
+
agentSessionId: session.agentSessionId,
|
|
269
|
+
claudeSessionId: session.claudeSessionId,
|
|
270
|
+
entryId: entry.id,
|
|
271
|
+
match: session.agentSessionId === entry.id,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const summary = entry.firstMessage ? entry.firstMessage.slice(0, 40) : '(新会话)';
|
|
275
|
+
sendSystem(ws, `🔄 已切换到第 ${index} 个 Session: "${summary}"\n下次消息将使用 --resume 恢复该会话上下文。\nResume ID: ${entry.id}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function handleDelete(indexOrId, ws, session) {
|
|
279
|
+
const history = session.agentSessionHistory;
|
|
280
|
+
if (!history || history.length === 0) {
|
|
281
|
+
sendError(ws, '❌ 当前 IM 会话下没有 Agent Session 记录。');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!indexOrId) {
|
|
286
|
+
sendError(ws, '❌ 请指定要删除的 Session 序号或 ID。使用 /list 查看可用 Session。');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = findEntryByIndexOrId(indexOrId, history);
|
|
291
|
+
if (!result) {
|
|
292
|
+
sendError(ws, `❌ 未找到匹配的 Session,序号范围: 1-${history.length}。`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { entry, index } = result;
|
|
297
|
+
const wasActive = entry.isActive || session.agentSessionId === entry.id;
|
|
298
|
+
|
|
299
|
+
removeAgentSessionFromHistory(session, entry.id);
|
|
300
|
+
|
|
301
|
+
const remaining = session.agentSessionHistory?.length || 0;
|
|
302
|
+
if (wasActive) {
|
|
303
|
+
sendSystem(ws, `🗑️ 已删除第 ${index} 个 Session(当前活跃会话),剩余 ${remaining} 个历史记录。`);
|
|
304
|
+
} else {
|
|
305
|
+
sendSystem(ws, `🗑️ 已删除第 ${index} 个 Session,剩余 ${remaining} 个历史记录。`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function handleUsage(ws, session) {
|
|
310
|
+
if ((session.agentType || 'claude') === 'codex') {
|
|
311
|
+
sendSystem(ws, '📊 Codex 当前暂不提供 Token 用量统计。');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const history = session.agentSessionHistory || [];
|
|
316
|
+
|
|
317
|
+
const activeEntry = history.find(e => e.isActive) || (session.agentSessionId ? history.find(e => e.id === session.agentSessionId) : null);
|
|
318
|
+
|
|
319
|
+
const current = session.usage || { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
320
|
+
const total = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
321
|
+
for (const entry of history) {
|
|
322
|
+
total.inputTokens += entry.usage?.inputTokens || 0;
|
|
323
|
+
total.outputTokens += entry.usage?.outputTokens || 0;
|
|
324
|
+
total.cacheReadTokens += entry.usage?.cacheReadTokens || 0;
|
|
325
|
+
total.cacheCreationTokens += entry.usage?.cacheCreationTokens || 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const grandTotal = total.inputTokens + total.outputTokens + total.cacheReadTokens + total.cacheCreationTokens;
|
|
329
|
+
|
|
330
|
+
let text = `📊 Token 用量统计:\n\n`;
|
|
331
|
+
text += `当前 Session:\n`;
|
|
332
|
+
text += ` Input: ${formatTokenCount(current.inputTokens)} | Output: ${formatTokenCount(current.outputTokens)}`;
|
|
333
|
+
if (current.cacheReadTokens || current.cacheCreationTokens) {
|
|
334
|
+
text += ` | Cache Read: ${formatTokenCount(current.cacheReadTokens)} | Cache Create: ${formatTokenCount(current.cacheCreationTokens)}`;
|
|
335
|
+
}
|
|
336
|
+
text += `\n\n`;
|
|
337
|
+
text += `全部 Session 累计:\n`;
|
|
338
|
+
text += ` Input: ${formatTokenCount(total.inputTokens)} | Output: ${formatTokenCount(total.outputTokens)}`;
|
|
339
|
+
if (total.cacheReadTokens || total.cacheCreationTokens) {
|
|
340
|
+
text += ` | Cache Read: ${formatTokenCount(total.cacheReadTokens)} | Cache Create: ${formatTokenCount(total.cacheCreationTokens)}`;
|
|
341
|
+
}
|
|
342
|
+
text += `\n 总计: ${formatTokenCount(grandTotal)} tokens`;
|
|
343
|
+
|
|
344
|
+
sendSystem(ws, text);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
handleSlashCommand,
|
|
349
|
+
};
|