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
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const { isDangerousCommand } = require('../danger');
|
|
3
|
+
const { send, sendError, sendSystem } = require('../protocol');
|
|
4
|
+
const { persistAgentSessionId, stopAgentProcess: stopSessionProcess, updateAgentSessionHistory } = require('../session');
|
|
5
|
+
const { getOutboxDir } = require('../outgoingAttachmentHandler');
|
|
6
|
+
const { createTextStreamBuffer, appendTextStream, flushTextStream, resetTextStream } = require('../streamBuffer');
|
|
7
|
+
|
|
8
|
+
function execute(input, ws, session, config) {
|
|
9
|
+
const textForCheck = stringifyInput(input);
|
|
10
|
+
if (isDangerousCommand(textForCheck)) {
|
|
11
|
+
const preview = textForCheck.slice(0, 200);
|
|
12
|
+
session.pendingDanger = { input };
|
|
13
|
+
send(ws, 'danger_warning', {
|
|
14
|
+
text: `⚠️ 检测到可能的危险操作,请确认是否继续执行:
|
|
15
|
+
|
|
16
|
+
"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
|
|
17
|
+
});
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (session.isTurnActive) {
|
|
22
|
+
if (session.messageQueue.length >= config.maxMessageQueue) {
|
|
23
|
+
sendError(ws, '消息队列已满,请稍后再试');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
session.messageQueue.push(input);
|
|
27
|
+
sendSystem(ws, `Codex 正在处理上一条消息,已加入队列(${session.messageQueue.length})`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const agentConfig = config.agents?.codex || {};
|
|
32
|
+
const mode = agentConfig.mode === 'exec' ? 'exec' : 'app-server';
|
|
33
|
+
if (mode === 'exec') {
|
|
34
|
+
runExecTurn(input, ws, session, config);
|
|
35
|
+
} else {
|
|
36
|
+
runAppServerTurn(input, ws, session, config);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── app-server persistent mode ────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function runAppServerTurn(input, ws, session, config) {
|
|
43
|
+
session.isTurnActive = true;
|
|
44
|
+
session.currentInputForNoOutput = input;
|
|
45
|
+
session.sawPartialAssistantText = false;
|
|
46
|
+
resetCodexAssistantText(session);
|
|
47
|
+
session._lastWs = ws;
|
|
48
|
+
session._lastConfig = config;
|
|
49
|
+
session._log = config.logger;
|
|
50
|
+
|
|
51
|
+
// Add outbox hint when user asks to send files
|
|
52
|
+
const inputWithOutbox = maybeAddOutboxHint(input, session, config);
|
|
53
|
+
|
|
54
|
+
const agentConfig = config.agents?.codex || {};
|
|
55
|
+
const log = config.logger;
|
|
56
|
+
log?.info('codex turn start', { mode: 'app-server', bin: agentConfig.bin, agentSessionId: session.agentSessionId });
|
|
57
|
+
|
|
58
|
+
ensureAppServer(session, config)
|
|
59
|
+
.then(() => {
|
|
60
|
+
session._log?.info('codex app-server ready, ensuring thread');
|
|
61
|
+
return ensureThread(session);
|
|
62
|
+
})
|
|
63
|
+
.then(threadId => {
|
|
64
|
+
session._log?.info('codex thread ensured', { threadId });
|
|
65
|
+
sendJsonRpc(session.codexAppServer, {
|
|
66
|
+
jsonrpc: '2.0',
|
|
67
|
+
id: nextRpcId(session),
|
|
68
|
+
method: 'turn/start',
|
|
69
|
+
params: {
|
|
70
|
+
threadId,
|
|
71
|
+
input: buildCodexInput(inputWithOutbox, session.workspace),
|
|
72
|
+
cwd: session.workspace,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
})
|
|
76
|
+
.catch(err => {
|
|
77
|
+
session._log?.error('codex turn error', { message: err.message });
|
|
78
|
+
if (session.isTurnActive) {
|
|
79
|
+
sendError(ws, `Codex app-server 错误: ${err.message}`);
|
|
80
|
+
clearTurnState(session);
|
|
81
|
+
drainQueue(ws, session, config);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ensureAppServer(session, config) {
|
|
87
|
+
if (session.codexAppServer && session.codexAppServer.stdin && !session.codexAppServer.stdin.destroyed) {
|
|
88
|
+
session._log?.info('codex reusing existing app-server');
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
session._log?.info('codex spawning new app-server');
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const agentConfig = config.agents?.codex || {};
|
|
96
|
+
const bin = agentConfig.bin || 'codex';
|
|
97
|
+
const child = spawn(bin, ['app-server', '--listen', 'stdio://'], {
|
|
98
|
+
cwd: session.workspace,
|
|
99
|
+
env: { ...process.env },
|
|
100
|
+
shell: process.platform === 'win32',
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
session.codexAppServer = child;
|
|
106
|
+
session.agentProcess = child;
|
|
107
|
+
session.codexRpcId = 0;
|
|
108
|
+
session.codexPendingRequests = new Map();
|
|
109
|
+
session.stdoutBuffer = '';
|
|
110
|
+
session.stderrBuffer = '';
|
|
111
|
+
session.turnCompletedTimerId = null;
|
|
112
|
+
|
|
113
|
+
let initialized = false;
|
|
114
|
+
let initResolve;
|
|
115
|
+
let initReject;
|
|
116
|
+
const initPromise = new Promise((res, rej) => {
|
|
117
|
+
initResolve = res;
|
|
118
|
+
initReject = rej;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const timeout = setTimeout(() => {
|
|
122
|
+
if (!initialized) {
|
|
123
|
+
initReject(new Error('Codex app-server 初始化超时'));
|
|
124
|
+
child.kill();
|
|
125
|
+
}
|
|
126
|
+
}, 15000);
|
|
127
|
+
|
|
128
|
+
child.stdout.setEncoding('utf8');
|
|
129
|
+
child.stdout.on('data', chunk => {
|
|
130
|
+
session.stdoutBuffer += chunk;
|
|
131
|
+
const lines = session.stdoutBuffer.split(/\r?\n/);
|
|
132
|
+
session.stdoutBuffer = lines.pop() || '';
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
if (!trimmed) continue;
|
|
136
|
+
try {
|
|
137
|
+
const msg = JSON.parse(trimmed);
|
|
138
|
+
handleAppServerMessage(msg, session);
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore unparseable lines
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
child.stderr.setEncoding('utf8');
|
|
146
|
+
child.stderr.on('data', chunk => {
|
|
147
|
+
session.stderrBuffer += chunk;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
child.on('error', err => {
|
|
151
|
+
if (!initialized) {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
initReject(err);
|
|
154
|
+
} else {
|
|
155
|
+
const errWs = session._lastWs;
|
|
156
|
+
if (errWs) {
|
|
157
|
+
sendError(errWs, `Codex app-server 错误: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
child.on('close', (code, signal) => {
|
|
163
|
+
if (session.codexAppServer === child) {
|
|
164
|
+
session.codexAppServer = null;
|
|
165
|
+
}
|
|
166
|
+
clearTurnState(session);
|
|
167
|
+
// drain pending requests
|
|
168
|
+
for (const [, pending] of session.codexPendingRequests) {
|
|
169
|
+
pending.reject(new Error(`app-server 已退出,code=${code}`));
|
|
170
|
+
}
|
|
171
|
+
session.codexPendingRequests.clear();
|
|
172
|
+
if (!initialized) {
|
|
173
|
+
clearTimeout(timeout);
|
|
174
|
+
initReject(new Error(`Codex app-server 启动失败,退出码: ${code}`));
|
|
175
|
+
} else {
|
|
176
|
+
// app-server exited mid-session — drain queue
|
|
177
|
+
const cfg = session._lastConfig;
|
|
178
|
+
const errWs = session._lastWs;
|
|
179
|
+
if (cfg && errWs) drainQueue(errWs, session, cfg);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// send initialize
|
|
184
|
+
const initId = ++session.codexRpcId;
|
|
185
|
+
sendJsonRpc(child, {
|
|
186
|
+
jsonrpc: '2.0',
|
|
187
|
+
id: initId,
|
|
188
|
+
method: 'initialize',
|
|
189
|
+
params: {
|
|
190
|
+
clientInfo: { name: 'linco', version: '1.0.0' },
|
|
191
|
+
capabilities: { experimentalApi: true },
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
session.codexPendingRequests.set(initId, {
|
|
196
|
+
resolve: (result) => {
|
|
197
|
+
initialized = true;
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
initResolve(result);
|
|
200
|
+
},
|
|
201
|
+
reject: (err) => {
|
|
202
|
+
clearTimeout(timeout);
|
|
203
|
+
initReject(err);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
initPromise.then(resolve, reject);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function ensureThread(session) {
|
|
212
|
+
const agentConfig = session._lastConfig?.agents?.codex || {};
|
|
213
|
+
|
|
214
|
+
if (session.agentSessionId) {
|
|
215
|
+
// Resume existing thread after app-server restart
|
|
216
|
+
const rpcId = nextRpcId(session);
|
|
217
|
+
return rpcRequest(session, rpcId, 'thread/resume', {
|
|
218
|
+
threadId: session.agentSessionId,
|
|
219
|
+
cwd: session.workspace,
|
|
220
|
+
approvalPolicy: 'untrusted',
|
|
221
|
+
}).then(result => {
|
|
222
|
+
return session.agentSessionId;
|
|
223
|
+
}).catch(err => {
|
|
224
|
+
// If resume fails, start a new thread
|
|
225
|
+
session._log?.warn('codex thread resume failed, starting new', { message: err.message });
|
|
226
|
+
return startNewThread(session, agentConfig);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return startNewThread(session, agentConfig);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function startNewThread(session, agentConfig) {
|
|
234
|
+
const rpcId = nextRpcId(session);
|
|
235
|
+
return rpcRequest(session, rpcId, 'thread/start', {
|
|
236
|
+
cwd: session.workspace,
|
|
237
|
+
model: agentConfig.model || null,
|
|
238
|
+
approvalPolicy: 'untrusted',
|
|
239
|
+
}).then(result => {
|
|
240
|
+
const threadId = result?.thread?.id || result?.id || result?.threadId;
|
|
241
|
+
if (threadId) {
|
|
242
|
+
persistAgentSessionId(session, threadId);
|
|
243
|
+
return threadId;
|
|
244
|
+
}
|
|
245
|
+
// Fallback: wait for thread/started notification
|
|
246
|
+
return new Promise((resolve, reject) => {
|
|
247
|
+
const fallback = setTimeout(() => reject(new Error('创建线程超时')), 15000);
|
|
248
|
+
session._threadStartResolve = (id) => {
|
|
249
|
+
clearTimeout(fallback);
|
|
250
|
+
resolve(id);
|
|
251
|
+
};
|
|
252
|
+
session._threadStartReject = (err) => {
|
|
253
|
+
clearTimeout(fallback);
|
|
254
|
+
reject(err);
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function clearTurnState(session) {
|
|
261
|
+
flushCodexAssistantText(session._lastWs, session);
|
|
262
|
+
session.isTurnActive = false;
|
|
263
|
+
session.currentInputForNoOutput = null;
|
|
264
|
+
if (session.turnCompletedTimerId) {
|
|
265
|
+
clearTimeout(session.turnCompletedTimerId);
|
|
266
|
+
session.turnCompletedTimerId = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ensureCodexStreamState(session) {
|
|
271
|
+
if (!session.codexStreamState) {
|
|
272
|
+
session.codexStreamState = createTextStreamBuffer();
|
|
273
|
+
}
|
|
274
|
+
return session.codexStreamState;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function appendCodexAssistantText(text, ws, session, ensureAssistantStarted) {
|
|
278
|
+
const state = ensureCodexStreamState(session);
|
|
279
|
+
state.onStart = ensureAssistantStarted;
|
|
280
|
+
appendTextStream(text, ws, state);
|
|
281
|
+
session.sawPartialAssistantText = true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function flushCodexAssistantText(ws, session) {
|
|
285
|
+
flushTextStream(ws, session.codexStreamState);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function resetCodexAssistantText(session) {
|
|
289
|
+
resetTextStream(ensureCodexStreamState(session));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function nextRpcId(session) {
|
|
293
|
+
session.codexRpcId = (session.codexRpcId || 0) + 1;
|
|
294
|
+
return session.codexRpcId;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function rpcRequest(session, id, method, params) {
|
|
298
|
+
return new Promise((resolve, reject) => {
|
|
299
|
+
session.codexPendingRequests.set(id, { resolve, reject });
|
|
300
|
+
sendJsonRpc(session.codexAppServer, {
|
|
301
|
+
jsonrpc: '2.0',
|
|
302
|
+
id,
|
|
303
|
+
method,
|
|
304
|
+
params,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function sendJsonRpc(child, message) {
|
|
310
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
311
|
+
child.stdin.write(JSON.stringify(message) + '\n');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function handleServerRequest(message, session) {
|
|
316
|
+
const method = message.method || '';
|
|
317
|
+
const params = message.params || {};
|
|
318
|
+
const ws = session._lastWs;
|
|
319
|
+
|
|
320
|
+
// File change approval — auto-approve but notify user
|
|
321
|
+
if (method === 'item/fileChange/requestApproval') {
|
|
322
|
+
session._log?.info('codex auto-approving file change');
|
|
323
|
+
sendJsonRpc(session.codexAppServer, {
|
|
324
|
+
jsonrpc: '2.0',
|
|
325
|
+
id: message.id,
|
|
326
|
+
result: { decision: 'accept' },
|
|
327
|
+
});
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Command execution approval — send to frontend for user approval
|
|
332
|
+
if (method === 'item/commandExecution/requestApproval' || method === 'execCommandApproval') {
|
|
333
|
+
const cmd = params.command || params.tool || '';
|
|
334
|
+
session._log?.info('codex command execution approval requested', { method, command: cmd });
|
|
335
|
+
|
|
336
|
+
session.pendingPermission = {
|
|
337
|
+
provider: 'codex',
|
|
338
|
+
toolName: 'exec',
|
|
339
|
+
input: cmd,
|
|
340
|
+
_codexMethod: method,
|
|
341
|
+
_rpcId: message.id,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (ws) {
|
|
345
|
+
send(ws, 'tool_call', {
|
|
346
|
+
id: String(message.id),
|
|
347
|
+
name: 'exec',
|
|
348
|
+
input: cmd,
|
|
349
|
+
});
|
|
350
|
+
send(ws, 'permission_request', {
|
|
351
|
+
requestId: String(message.id),
|
|
352
|
+
toolName: 'exec',
|
|
353
|
+
input: cmd,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Permissions approval — auto-grant
|
|
360
|
+
if (method === 'item/permissions/requestApproval') {
|
|
361
|
+
session._log?.info('codex auto-granting permissions');
|
|
362
|
+
sendJsonRpc(session.codexAppServer, {
|
|
363
|
+
jsonrpc: '2.0',
|
|
364
|
+
id: message.id,
|
|
365
|
+
result: { permissions: { fileSystem: { entries: [] }, network: { enabled: false } }, scope: 'session' },
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Apply patch approval — auto-approve
|
|
371
|
+
if (method === 'applyPatchApproval') {
|
|
372
|
+
session._log?.info('codex auto-approving patch');
|
|
373
|
+
sendJsonRpc(session.codexAppServer, {
|
|
374
|
+
jsonrpc: '2.0',
|
|
375
|
+
id: message.id,
|
|
376
|
+
result: { decision: 'approved' },
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Generic approval fallback
|
|
382
|
+
if (method.includes('requestApproval') || method.includes('approval')) {
|
|
383
|
+
session._log?.info('codex auto-approving', { method });
|
|
384
|
+
sendJsonRpc(session.codexAppServer, {
|
|
385
|
+
jsonrpc: '2.0',
|
|
386
|
+
id: message.id,
|
|
387
|
+
result: { decision: 'accept' },
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Tool call from server request
|
|
393
|
+
if (method === 'item/tool/call') {
|
|
394
|
+
if (ws) {
|
|
395
|
+
const toolName = params.name || params.tool || '';
|
|
396
|
+
send(ws, 'tool_call', {
|
|
397
|
+
id: String(message.id),
|
|
398
|
+
name: toolName,
|
|
399
|
+
input: JSON.stringify(params.input || {}).slice(0, 300),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
sendJsonRpc(session.codexAppServer, {
|
|
403
|
+
jsonrpc: '2.0',
|
|
404
|
+
id: message.id,
|
|
405
|
+
result: { ok: true },
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Tool user input
|
|
411
|
+
if (method === 'item/tool/requestUserInput') {
|
|
412
|
+
session._log?.info('codex tool requesting user input');
|
|
413
|
+
sendJsonRpc(session.codexAppServer, {
|
|
414
|
+
jsonrpc: '2.0',
|
|
415
|
+
id: message.id,
|
|
416
|
+
result: { continue: true },
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Unknown server request — reject
|
|
422
|
+
session._log?.warn('codex unknown server request', { method, params: JSON.stringify(params).slice(0, 200) });
|
|
423
|
+
sendJsonRpc(session.codexAppServer, {
|
|
424
|
+
jsonrpc: '2.0',
|
|
425
|
+
id: message.id,
|
|
426
|
+
error: { code: -32601, message: 'method not supported' },
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function handleAppServerMessage(message, session) {
|
|
431
|
+
const ws = session._lastWs;
|
|
432
|
+
|
|
433
|
+
// thread.started notification — resolve ensureThread promise
|
|
434
|
+
if (message.method === 'thread.started' || message.method === 'thread/start') {
|
|
435
|
+
const threadId = message.params?.thread?.id || message.params?.id || message.result?.thread?.id;
|
|
436
|
+
if (threadId) {
|
|
437
|
+
persistAgentSessionId(session, threadId);
|
|
438
|
+
if (session._threadStartResolve) {
|
|
439
|
+
session._threadStartResolve(threadId);
|
|
440
|
+
session._threadStartResolve = null;
|
|
441
|
+
session._threadStartReject = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// RPC response (has id field matching our pending request)
|
|
448
|
+
if (message.id != null && session.codexPendingRequests?.has(message.id)) {
|
|
449
|
+
const pending = session.codexPendingRequests.get(message.id);
|
|
450
|
+
session.codexPendingRequests.delete(message.id);
|
|
451
|
+
if (message.error) {
|
|
452
|
+
session._log?.error('codex RPC error', { id: message.id, error: JSON.stringify(message.error) });
|
|
453
|
+
pending.reject(new Error(JSON.stringify(message.error)));
|
|
454
|
+
} else {
|
|
455
|
+
session._log?.info('codex RPC response', { id: message.id, keys: Object.keys(message.result || {}) });
|
|
456
|
+
pending.resolve(message.result || message);
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Server request (has id but not our pending) — auto-approve
|
|
462
|
+
if (message.id != null && message.method) {
|
|
463
|
+
handleServerRequest(message, session);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// notification
|
|
468
|
+
const method = message.method || '';
|
|
469
|
+
const params = message.params || {};
|
|
470
|
+
|
|
471
|
+
if (method === 'item/agentMessage/delta' || method.includes('delta')) {
|
|
472
|
+
const delta = params.delta || '';
|
|
473
|
+
if (delta) {
|
|
474
|
+
appendCodexAssistantText(delta, ws, session, () => send(ws, 'assistant_start', {}));
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (method === 'item/completed') {
|
|
480
|
+
// final content fallback if no deltas were emitted
|
|
481
|
+
const text = extractFinalText(params);
|
|
482
|
+
if (text && !session.sawPartialAssistantText) {
|
|
483
|
+
appendCodexAssistantText(text, ws, session, () => send(ws, 'assistant_start', {}));
|
|
484
|
+
}
|
|
485
|
+
// Safety fallback: if turn/completed doesn't arrive within 3s,
|
|
486
|
+
// clear isTurnActive so the next message doesn't get stuck.
|
|
487
|
+
if (session.turnCompletedTimerId) clearTimeout(session.turnCompletedTimerId);
|
|
488
|
+
session.turnCompletedTimerId = setTimeout(() => {
|
|
489
|
+
if (session.isTurnActive) {
|
|
490
|
+
session.isTurnActive = false;
|
|
491
|
+
session.currentInputForNoOutput = null;
|
|
492
|
+
if (session.sawPartialAssistantText) {
|
|
493
|
+
flushCodexAssistantText(ws, session);
|
|
494
|
+
send(ws, 'assistant_end', {});
|
|
495
|
+
}
|
|
496
|
+
const cfg = session._lastConfig;
|
|
497
|
+
if (cfg) drainQueue(ws, session, cfg);
|
|
498
|
+
}
|
|
499
|
+
}, 3000);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (method === 'turn/completed' || method === 'turn.completed') {
|
|
504
|
+
if (session.turnCompletedTimerId) {
|
|
505
|
+
clearTimeout(session.turnCompletedTimerId);
|
|
506
|
+
session.turnCompletedTimerId = null;
|
|
507
|
+
}
|
|
508
|
+
updateCodexSessionStats(session, params);
|
|
509
|
+
clearTurnState(session);
|
|
510
|
+
if (session.sawPartialAssistantText) {
|
|
511
|
+
flushCodexAssistantText(ws, session);
|
|
512
|
+
send(ws, 'assistant_end', {});
|
|
513
|
+
} else {
|
|
514
|
+
sendSystem(ws, 'Codex 本次执行没有输出。');
|
|
515
|
+
}
|
|
516
|
+
const cfg = session._lastConfig;
|
|
517
|
+
if (cfg) {
|
|
518
|
+
drainQueue(ws, session, cfg);
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (method === 'error' || method.includes('error')) {
|
|
524
|
+
sendError(ws, params.message || JSON.stringify(params));
|
|
525
|
+
clearTurnState(session);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// tool call notifications
|
|
530
|
+
if (method === 'tool/start' || method === 'tool_call') {
|
|
531
|
+
const toolName = params.name || params.tool || '';
|
|
532
|
+
if (toolName) {
|
|
533
|
+
send(ws, 'tool_call', {
|
|
534
|
+
id: params.id || params.toolId || '',
|
|
535
|
+
name: toolName,
|
|
536
|
+
input: params.input || params.arguments || {},
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (method === 'tool/completed' || method === 'tool_result') {
|
|
543
|
+
send(ws, 'tool_result', {
|
|
544
|
+
id: params.id || params.toolId || '',
|
|
545
|
+
output: params.output || params.result || '',
|
|
546
|
+
});
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (method === 'item/started') {
|
|
551
|
+
const itemType = params.item?.type || '';
|
|
552
|
+
session._log?.info('codex item started', { itemType });
|
|
553
|
+
if (itemType === 'toolCall' || itemType === 'commandExecution') {
|
|
554
|
+
const toolName = params.item?.name || params.item?.tool || params.item?.command || '';
|
|
555
|
+
const toolInput = params.item?.input || params.item?.arguments || {};
|
|
556
|
+
const itemId = params.item?.id || params.itemId || '';
|
|
557
|
+
if (toolName) {
|
|
558
|
+
send(ws, 'tool_call', {
|
|
559
|
+
id: itemId,
|
|
560
|
+
name: toolName,
|
|
561
|
+
input: typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput).slice(0, 300),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function updateCodexSessionStats(session, params = {}) {
|
|
570
|
+
session.messageCount = (session.messageCount || 0) + 1;
|
|
571
|
+
|
|
572
|
+
const usage = params.usage || params.turn?.usage || params.response?.usage;
|
|
573
|
+
if (usage) {
|
|
574
|
+
if (!session.usage) {
|
|
575
|
+
session.usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
576
|
+
}
|
|
577
|
+
session.usage.inputTokens += usage.input_tokens || usage.inputTokens || 0;
|
|
578
|
+
session.usage.outputTokens += usage.output_tokens || usage.outputTokens || 0;
|
|
579
|
+
session.usage.cacheReadTokens += usage.cache_read_input_tokens || usage.cacheReadTokens || 0;
|
|
580
|
+
session.usage.cacheCreationTokens += usage.cache_creation_input_tokens || usage.cacheCreationTokens || 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
updateAgentSessionHistory(session);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function extractFinalText(params) {
|
|
587
|
+
if (typeof params.text === 'string') return params.text;
|
|
588
|
+
if (typeof params.delta === 'string') return params.delta;
|
|
589
|
+
if (Array.isArray(params.content)) {
|
|
590
|
+
return params.content.map(item => typeof item === 'string' ? item : item?.text || '').join('');
|
|
591
|
+
}
|
|
592
|
+
if (params.item?.type === 'agent_message' && typeof params.item.text === 'string') {
|
|
593
|
+
return params.item.text;
|
|
594
|
+
}
|
|
595
|
+
return '';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function buildCodexInput(input, workspace) {
|
|
599
|
+
if (Array.isArray(input)) {
|
|
600
|
+
const result = [];
|
|
601
|
+
for (const block of input) {
|
|
602
|
+
if (typeof block === 'string') {
|
|
603
|
+
result.push({ type: 'text', text: block });
|
|
604
|
+
} else if (block?.type === 'text') {
|
|
605
|
+
result.push({ type: 'text', text: block.text || '' });
|
|
606
|
+
} else if (block?.type === 'image' && block.path) {
|
|
607
|
+
// Codex sandbox can only read from workspace — copy image into workspace
|
|
608
|
+
const fs = require('fs');
|
|
609
|
+
const path = require('path');
|
|
610
|
+
const basename = path.basename(block.path);
|
|
611
|
+
const copyDir = path.join(workspace, '_linco_attachments');
|
|
612
|
+
const copyPath = path.join(copyDir, basename);
|
|
613
|
+
try {
|
|
614
|
+
fs.mkdirSync(copyDir, { recursive: true });
|
|
615
|
+
fs.copyFileSync(block.path, copyPath);
|
|
616
|
+
} catch {
|
|
617
|
+
// If copy fails, fall back to original path
|
|
618
|
+
}
|
|
619
|
+
const mediaType = block.source?.media_type || '';
|
|
620
|
+
result.push({ type: 'text', text: `用户发送了一张图片(${mediaType}),文件已保存到 ${copyPath},请按需读取` });
|
|
621
|
+
} else if (block?.type === 'image') {
|
|
622
|
+
result.push({ type: 'text', text: '[图片附件]' });
|
|
623
|
+
} else {
|
|
624
|
+
result.push({ type: 'text', text: JSON.stringify(block) });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
return [{ type: 'text', text: String(input || '') }];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function maybeAddOutboxHint(input, session, config) {
|
|
633
|
+
const text = typeof input === 'string' ? input : stringifyInput(input);
|
|
634
|
+
if (!/发送.*(文件|图片)|把.*(文件|图片).*发|发给我|发送给我|文件发送|发文件|下载文件/.test(text)) {
|
|
635
|
+
return input;
|
|
636
|
+
}
|
|
637
|
+
const outboxDir = getOutboxDir(session, config);
|
|
638
|
+
const hint = `系统提示:用户正在要求发送文件/图片。请将要发送给用户的最终文件保存或复制到以下 outbox 目录,系统只会自动发送这个目录中的新文件:\n${outboxDir}`;
|
|
639
|
+
if (Array.isArray(input)) {
|
|
640
|
+
return [...input, { type: 'text', text: hint }];
|
|
641
|
+
}
|
|
642
|
+
return `${text}\n\n${hint}`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ─── exec fallback mode ────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
function runExecTurn(input, ws, session, config) {
|
|
648
|
+
session.isTurnActive = true;
|
|
649
|
+
session.currentInputForNoOutput = input;
|
|
650
|
+
session.sawPartialAssistantText = false;
|
|
651
|
+
resetCodexAssistantText(session);
|
|
652
|
+
|
|
653
|
+
const agentConfig = config.agents?.codex || {};
|
|
654
|
+
const child = spawn(agentConfig.bin || 'codex', buildExecArgs(session, agentConfig), {
|
|
655
|
+
cwd: session.workspace,
|
|
656
|
+
env: { ...process.env },
|
|
657
|
+
shell: process.platform === 'win32',
|
|
658
|
+
windowsHide: true,
|
|
659
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
session.agentProcess = child;
|
|
663
|
+
session.stdoutBuffer = '';
|
|
664
|
+
let stderr = '';
|
|
665
|
+
let assistantStarted = false;
|
|
666
|
+
|
|
667
|
+
const ensureAssistantStarted = () => {
|
|
668
|
+
if (assistantStarted) return;
|
|
669
|
+
assistantStarted = true;
|
|
670
|
+
send(ws, 'assistant_start', {});
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
child.stdout.setEncoding('utf8');
|
|
674
|
+
child.stdout.on('data', chunk => {
|
|
675
|
+
session.stdoutBuffer += chunk;
|
|
676
|
+
const lines = session.stdoutBuffer.split(/\r?\n/);
|
|
677
|
+
session.stdoutBuffer = lines.pop() || '';
|
|
678
|
+
for (const line of lines) {
|
|
679
|
+
const trimmed = line.trim();
|
|
680
|
+
if (!trimmed) continue;
|
|
681
|
+
try {
|
|
682
|
+
handleCodexEvent(JSON.parse(trimmed), ws, session, ensureAssistantStarted);
|
|
683
|
+
} catch {
|
|
684
|
+
appendCodexAssistantText(`${trimmed}\n`, ws, session, ensureAssistantStarted);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
child.stderr.setEncoding('utf8');
|
|
690
|
+
child.stderr.on('data', chunk => {
|
|
691
|
+
stderr += chunk;
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
child.on('error', err => {
|
|
695
|
+
sendError(ws, `Codex 启动失败: ${err.message}`);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
child.on('close', code => {
|
|
699
|
+
if (session.agentProcess === child) session.agentProcess = null;
|
|
700
|
+
session.isTurnActive = false;
|
|
701
|
+
session.currentInputForNoOutput = null;
|
|
702
|
+
|
|
703
|
+
if (session.stdoutBuffer.trim()) {
|
|
704
|
+
appendCodexAssistantText(`${session.stdoutBuffer.trim()}\n`, ws, session, ensureAssistantStarted);
|
|
705
|
+
session.stdoutBuffer = '';
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (assistantStarted) {
|
|
709
|
+
flushCodexAssistantText(ws, session);
|
|
710
|
+
send(ws, 'assistant_end', {});
|
|
711
|
+
} else if (code !== 0) {
|
|
712
|
+
sendError(ws, stderr.trim() || `Codex 退出,状态码: ${code}`);
|
|
713
|
+
} else {
|
|
714
|
+
sendSystem(ws, 'Codex 本次执行没有输出。');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (code === 0) {
|
|
718
|
+
updateCodexSessionStats(session);
|
|
719
|
+
}
|
|
720
|
+
drainQueue(ws, session, config);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
child.stdin.end(`${stringifyInput(input)}\n`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function buildExecArgs(session, agentConfig) {
|
|
727
|
+
const args = session.agentSessionId
|
|
728
|
+
? ['exec', 'resume']
|
|
729
|
+
: ['exec'];
|
|
730
|
+
args.push('--json');
|
|
731
|
+
if (!session.agentSessionId) args.push('--cd', session.workspace);
|
|
732
|
+
if (agentConfig.model) args.push('--model', agentConfig.model);
|
|
733
|
+
if (session.agentSessionId) args.push(session.agentSessionId);
|
|
734
|
+
args.push('-');
|
|
735
|
+
return args;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ─── shared helpers ─────────────────────────────────────────────────────
|
|
739
|
+
|
|
740
|
+
function stringifyInput(input) {
|
|
741
|
+
if (Array.isArray(input)) {
|
|
742
|
+
return input.map(block => {
|
|
743
|
+
if (typeof block === 'string') return block;
|
|
744
|
+
if (block?.type === 'text') return block.text || '';
|
|
745
|
+
if (block?.type === 'image') return '[图片附件:Codex 当前适配器暂不直接传入图片内容]';
|
|
746
|
+
return JSON.stringify(block);
|
|
747
|
+
}).filter(Boolean).join('\n');
|
|
748
|
+
}
|
|
749
|
+
return String(input || '');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function handleCodexEvent(event, ws, session, ensureAssistantStarted) {
|
|
753
|
+
const type = event.type || event.event || event.kind || '';
|
|
754
|
+
const threadId = event.thread_id || event.threadId || event.thread?.id;
|
|
755
|
+
if ((type === 'thread.started' || type === 'thread_started') && threadId) {
|
|
756
|
+
persistAgentSessionId(session, threadId);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (event.thread_id && !session.agentSessionId) {
|
|
761
|
+
persistAgentSessionId(session, event.thread_id);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const text = extractText(event);
|
|
765
|
+
if (text) {
|
|
766
|
+
appendCodexAssistantText(text, ws, session, ensureAssistantStarted);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (type.includes('error') || type === 'turn.failed') {
|
|
771
|
+
sendError(ws, event.message || event.error || JSON.stringify(event));
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function extractText(event) {
|
|
776
|
+
if (typeof event.text === 'string') return event.text;
|
|
777
|
+
if (typeof event.delta === 'string') return event.delta;
|
|
778
|
+
if (typeof event.message === 'string' && /message|agent/.test(String(event.type || ''))) return event.message;
|
|
779
|
+
if (Array.isArray(event.content)) {
|
|
780
|
+
return event.content.map(item => typeof item === 'string' ? item : item?.text || '').join('');
|
|
781
|
+
}
|
|
782
|
+
if (typeof event.item?.text === 'string' && ['agent_message', 'message'].includes(event.item.type)) {
|
|
783
|
+
return event.item.text;
|
|
784
|
+
}
|
|
785
|
+
if (event.item?.type === 'message' && Array.isArray(event.item.content)) {
|
|
786
|
+
return event.item.content.map(item => item?.text || '').join('');
|
|
787
|
+
}
|
|
788
|
+
return '';
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function resolvePendingDanger(confirmed, ws, session, config) {
|
|
792
|
+
const pending = session.pendingDanger;
|
|
793
|
+
if (!pending) return false;
|
|
794
|
+
session.pendingDanger = null;
|
|
795
|
+
if (!confirmed) {
|
|
796
|
+
sendSystem(ws, '已取消危险操作。');
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
execute(pending.input, ws, session, config);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function resolvePendingPermission(approved, ws, session, config) {
|
|
804
|
+
if (!session.pendingPermission) return false;
|
|
805
|
+
|
|
806
|
+
const pending = session.pendingPermission;
|
|
807
|
+
if (pending.provider !== 'codex') return false;
|
|
808
|
+
|
|
809
|
+
session.pendingPermission = null;
|
|
810
|
+
session._log?.info('codex permission response', { approved, toolName: pending.toolName, rpcId: pending._rpcId });
|
|
811
|
+
|
|
812
|
+
// Respond to Codex RPC
|
|
813
|
+
if (pending._codexMethod === 'item/commandExecution/requestApproval' || pending._codexMethod === 'execCommandApproval') {
|
|
814
|
+
if (approved) {
|
|
815
|
+
sendJsonRpc(session.codexAppServer, {
|
|
816
|
+
jsonrpc: '2.0',
|
|
817
|
+
id: pending._rpcId,
|
|
818
|
+
result: { decision: 'accept' },
|
|
819
|
+
});
|
|
820
|
+
sendSystem(ws, '✅ 已批准命令执行。');
|
|
821
|
+
} else {
|
|
822
|
+
sendJsonRpc(session.codexAppServer, {
|
|
823
|
+
jsonrpc: '2.0',
|
|
824
|
+
id: pending._rpcId,
|
|
825
|
+
result: { decision: 'reject' },
|
|
826
|
+
});
|
|
827
|
+
sendSystem(ws, '🚫 已拒绝命令执行。');
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function stop(session, options = {}) {
|
|
835
|
+
if (session.codexAppServer) {
|
|
836
|
+
try {
|
|
837
|
+
session.codexAppServer.kill();
|
|
838
|
+
} catch {
|
|
839
|
+
// ignore
|
|
840
|
+
}
|
|
841
|
+
session.codexAppServer = null;
|
|
842
|
+
}
|
|
843
|
+
stopSessionProcess(session, options);
|
|
844
|
+
clearTurnState(session);
|
|
845
|
+
if (session.codexPendingRequests) {
|
|
846
|
+
for (const [, pending] of session.codexPendingRequests) {
|
|
847
|
+
pending.reject(new Error('用户已停止'));
|
|
848
|
+
}
|
|
849
|
+
session.codexPendingRequests.clear();
|
|
850
|
+
}
|
|
851
|
+
if (session._threadStartResolve) {
|
|
852
|
+
session._threadStartResolve = null;
|
|
853
|
+
session._threadStartReject = null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function drainQueue(ws, session, config) {
|
|
858
|
+
const next = session.messageQueue.shift();
|
|
859
|
+
if (!next) return;
|
|
860
|
+
session._lastConfig = config;
|
|
861
|
+
setImmediate(() => execute(next, ws, session, config));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
module.exports = {
|
|
865
|
+
execute,
|
|
866
|
+
resolvePendingDanger,
|
|
867
|
+
resolvePendingPermission,
|
|
868
|
+
stop,
|
|
869
|
+
};
|