copilot-proxy-web 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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.arch.md +87 -0
- package/README.md +295 -0
- package/bin/run-web.js +548 -0
- package/bin/wss-client.js +227 -0
- package/copilot-proxy.js +1114 -0
- package/lib/api.js +564 -0
- package/lib/auth-rate-limit.js +59 -0
- package/lib/cli.js +273 -0
- package/lib/cloudflare-service.js +326 -0
- package/lib/cloudflare-setup-deps.js +136 -0
- package/lib/cloudflare-setup-service.js +277 -0
- package/lib/cloudflare-state.js +100 -0
- package/lib/cloudflare-utils.js +69 -0
- package/lib/conversation-profiles.js +80 -0
- package/lib/daemon-service.js +210 -0
- package/lib/daemon-state.js +55 -0
- package/lib/format.js +29 -0
- package/lib/hooks.js +29 -0
- package/lib/log-rotate.js +109 -0
- package/lib/markdown.js +40 -0
- package/lib/pty.js +13 -0
- package/lib/telegram.js +124 -0
- package/lib/terminal-buffer.js +302 -0
- package/lib/ws.js +256 -0
- package/package.json +51 -0
- package/public/index.html +2850 -0
package/copilot-proxy.js
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const http = require("node:http");
|
|
6
|
+
const { execSync } = require("node:child_process");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const pty = require("node-pty");
|
|
9
|
+
const express = require("express");
|
|
10
|
+
const { stripAnsi } = require("./lib/format");
|
|
11
|
+
const { formatMarkdownEvent } = require("./lib/markdown");
|
|
12
|
+
const { attachWsServer } = require("./lib/ws");
|
|
13
|
+
const { createHookRegistry } = require("./lib/hooks");
|
|
14
|
+
const { parseArgs } = require("./lib/cli");
|
|
15
|
+
const { createApiRouter } = require("./lib/api");
|
|
16
|
+
const { formatTelegramMarkdownEvent, sendWithRetry } = require("./lib/telegram");
|
|
17
|
+
const { TerminalBuffer } = require("./lib/terminal-buffer");
|
|
18
|
+
const { detectProfileFromChunk, filterConversationLines } = require("./lib/conversation-profiles");
|
|
19
|
+
const { rotateLogFile } = require("./lib/log-rotate");
|
|
20
|
+
const { version: appVersion } = require("./package.json");
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const isCli = require.main === module;
|
|
24
|
+
const {
|
|
25
|
+
logPath,
|
|
26
|
+
webEnabled,
|
|
27
|
+
webPlain,
|
|
28
|
+
apiEnabled,
|
|
29
|
+
host,
|
|
30
|
+
port,
|
|
31
|
+
apiPort,
|
|
32
|
+
useShell,
|
|
33
|
+
debugWs,
|
|
34
|
+
debugWsPath,
|
|
35
|
+
ptyCols,
|
|
36
|
+
ptyRows,
|
|
37
|
+
idleMs,
|
|
38
|
+
idleLogPath,
|
|
39
|
+
idleClean,
|
|
40
|
+
idleMarkdown,
|
|
41
|
+
idleMarkdownMaxChars,
|
|
42
|
+
idleBufferMaxChars: cliIdleBufferMaxChars,
|
|
43
|
+
idleBurstMs: cliIdleBurstMs,
|
|
44
|
+
idlePacketMaxBytes: cliIdlePacketMaxBytes,
|
|
45
|
+
conversationContext: cliConversationContext,
|
|
46
|
+
conversationProfile: cliConversationProfile,
|
|
47
|
+
tgToken: cliTgToken,
|
|
48
|
+
tgChat: cliTgChat,
|
|
49
|
+
tgProxy: cliTgProxy,
|
|
50
|
+
tgParseMode: cliTgParseMode,
|
|
51
|
+
tgRetry: cliTgRetry,
|
|
52
|
+
tgBackoffMs: cliTgBackoffMs,
|
|
53
|
+
tgTimeoutMs: cliTgTimeoutMs,
|
|
54
|
+
tgMaxChars: cliTgMaxChars,
|
|
55
|
+
authToken: cliAuthToken,
|
|
56
|
+
useXForwardedFor: cliUseXForwardedFor,
|
|
57
|
+
enforceAuthOnPublic: cliEnforceAuthOnPublic,
|
|
58
|
+
noDefaultSession,
|
|
59
|
+
noStdin,
|
|
60
|
+
noStdout,
|
|
61
|
+
cmd,
|
|
62
|
+
args,
|
|
63
|
+
} = parseArgs(argv);
|
|
64
|
+
|
|
65
|
+
function readEnvNumber(value) {
|
|
66
|
+
if (value === undefined || value === null || value === "") return null;
|
|
67
|
+
const num = Number(value);
|
|
68
|
+
return Number.isFinite(num) ? num : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tgToken = cliTgToken ?? process.env.TG_TOKEN ?? "";
|
|
72
|
+
const tgChat = cliTgChat ?? process.env.TG_CHAT ?? "";
|
|
73
|
+
const tgProxy = cliTgProxy ?? process.env.TG_PROXY ?? "";
|
|
74
|
+
const tgParseMode = (cliTgParseMode ?? process.env.TG_PARSE_MODE ?? "MarkdownV2");
|
|
75
|
+
const tgRetry = cliTgRetry ?? readEnvNumber(process.env.TG_RETRY) ?? 2;
|
|
76
|
+
const tgBackoffMs = cliTgBackoffMs ?? readEnvNumber(process.env.TG_BACKOFF_MS) ?? 1000;
|
|
77
|
+
const tgTimeoutMs = cliTgTimeoutMs ?? readEnvNumber(process.env.TG_TIMEOUT_MS) ?? 10000;
|
|
78
|
+
const tgMaxChars = cliTgMaxChars ?? readEnvNumber(process.env.TG_MAX_CHARS) ?? 3500;
|
|
79
|
+
const idleBufferMaxChars = cliIdleBufferMaxChars ?? readEnvNumber(process.env.IDLE_BUFFER_MAX_CHARS) ?? 20000;
|
|
80
|
+
const idleBurstMs = cliIdleBurstMs ?? readEnvNumber(process.env.IDLE_BURST_MS) ?? 200;
|
|
81
|
+
const idlePacketMaxBytes = cliIdlePacketMaxBytes
|
|
82
|
+
?? readEnvNumber(process.env.IDLE_PACKET_MAX_BYTES)
|
|
83
|
+
?? 128;
|
|
84
|
+
const conversationContext = cliConversationContext
|
|
85
|
+
?? readEnvNumber(process.env.CONVERSATION_CONTEXT)
|
|
86
|
+
?? 1;
|
|
87
|
+
const conversationProfile = (cliConversationProfile ?? process.env.CONVERSATION_PROFILE ?? "none")
|
|
88
|
+
.toString()
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
const tgEnabled = Boolean(tgToken && tgChat);
|
|
91
|
+
const authToken = cliAuthToken ?? process.env.AUTH_TOKEN ?? "";
|
|
92
|
+
const useXForwardedFor = Boolean(
|
|
93
|
+
cliUseXForwardedFor || /^(1|true|yes|on)$/i.test(String(process.env.USE_X_FORWARDED_FOR || ""))
|
|
94
|
+
);
|
|
95
|
+
const enforceAuthOnPublic = Boolean(
|
|
96
|
+
cliEnforceAuthOnPublic || /^(1|true|yes|on)$/i.test(String(process.env.ENFORCE_AUTH_ON_PUBLIC || ""))
|
|
97
|
+
);
|
|
98
|
+
const accessLogMode = (process.env.ACCESS_LOG_MODE || "auth").toLowerCase();
|
|
99
|
+
const logRotateMaxBytes =
|
|
100
|
+
readEnvNumber(process.env.LOG_ROTATE_MAX_BYTES) ?? 10 * 1024 * 1024;
|
|
101
|
+
const logRotateRetainDays =
|
|
102
|
+
readEnvNumber(process.env.LOG_ROTATE_RETAIN_DAYS) ?? 14;
|
|
103
|
+
|
|
104
|
+
let logDir = null;
|
|
105
|
+
let logFile = null;
|
|
106
|
+
let accessLogFile = null;
|
|
107
|
+
if (isCli) {
|
|
108
|
+
const resolved = path.resolve(logPath);
|
|
109
|
+
try {
|
|
110
|
+
const stat = fs.statSync(resolved);
|
|
111
|
+
if (stat.isDirectory()) {
|
|
112
|
+
logDir = resolved;
|
|
113
|
+
} else {
|
|
114
|
+
logFile = resolved;
|
|
115
|
+
logDir = path.dirname(resolved);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
if (logPath.endsWith(path.sep)) {
|
|
119
|
+
logDir = resolved;
|
|
120
|
+
} else {
|
|
121
|
+
logFile = resolved;
|
|
122
|
+
logDir = path.dirname(resolved);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (logDir) {
|
|
126
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
127
|
+
accessLogFile = path.join(logDir, "access.log");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isCli && logFile) {
|
|
132
|
+
rotateLogFile(logFile, {
|
|
133
|
+
maxBytes: logRotateMaxBytes,
|
|
134
|
+
retainDays: logRotateRetainDays,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (isCli && idleLogPath) {
|
|
138
|
+
rotateLogFile(path.resolve(idleLogPath), {
|
|
139
|
+
maxBytes: logRotateMaxBytes,
|
|
140
|
+
retainDays: logRotateRetainDays,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (debugWs && debugWsPath) {
|
|
144
|
+
rotateLogFile(path.resolve(debugWsPath), {
|
|
145
|
+
maxBytes: logRotateMaxBytes,
|
|
146
|
+
retainDays: logRotateRetainDays,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (isCli && accessLogFile) {
|
|
150
|
+
rotateLogFile(accessLogFile, {
|
|
151
|
+
maxBytes: logRotateMaxBytes,
|
|
152
|
+
retainDays: logRotateRetainDays,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const logStream = isCli && logFile
|
|
157
|
+
? fs.createWriteStream(logFile, { flags: "a" })
|
|
158
|
+
: null;
|
|
159
|
+
const accessLogStream = isCli && accessLogFile
|
|
160
|
+
? fs.createWriteStream(accessLogFile, { flags: "a" })
|
|
161
|
+
: null;
|
|
162
|
+
const debugStream = debugWs
|
|
163
|
+
? (debugWsPath
|
|
164
|
+
? fs.createWriteStream(path.resolve(debugWsPath), { flags: "a" })
|
|
165
|
+
: process.stdout)
|
|
166
|
+
: null;
|
|
167
|
+
const idleLogStream = isCli && idleLogPath
|
|
168
|
+
? fs.createWriteStream(path.resolve(idleLogPath), { flags: "a" })
|
|
169
|
+
: null;
|
|
170
|
+
|
|
171
|
+
function logEvent(message) {
|
|
172
|
+
if (!isCli) return;
|
|
173
|
+
const ts = new Date().toISOString();
|
|
174
|
+
process.stdout.write(`[${ts}] ${message}\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function logAccess(payload) {
|
|
178
|
+
if (!accessLogStream) return;
|
|
179
|
+
const record = {
|
|
180
|
+
ts: new Date().toISOString(),
|
|
181
|
+
...payload,
|
|
182
|
+
};
|
|
183
|
+
accessLogStream.write(`${JSON.stringify(record)}\n`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveExecutable(bin) {
|
|
187
|
+
if (bin.includes("/")) return bin;
|
|
188
|
+
const pathEnv = process.env.PATH || "";
|
|
189
|
+
const parts = pathEnv.split(path.delimiter);
|
|
190
|
+
for (const dir of parts) {
|
|
191
|
+
if (!dir) continue;
|
|
192
|
+
const candidate = path.join(dir, bin);
|
|
193
|
+
try {
|
|
194
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
195
|
+
return candidate;
|
|
196
|
+
} catch {
|
|
197
|
+
// continue
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return bin;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function shellQuote(arg) {
|
|
204
|
+
if (arg === "") return "''";
|
|
205
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(arg)) return arg;
|
|
206
|
+
return `'${arg.replace(/'/g, `'\"'\"'`)}'`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const MAX_HISTORY = 2000;
|
|
210
|
+
const MAX_HOOK_EVENTS = 50;
|
|
211
|
+
const DEFAULT_SESSION_ID = "default";
|
|
212
|
+
|
|
213
|
+
function buildSpawnCommand(runCmd, runArgs, runShell, runShellInteractive) {
|
|
214
|
+
let spawnCmd = runCmd;
|
|
215
|
+
let spawnArgs = runArgs;
|
|
216
|
+
const shouldShell = Boolean(runShell ?? useShell);
|
|
217
|
+
if (shouldShell) {
|
|
218
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
219
|
+
let joined = "";
|
|
220
|
+
if (!runArgs || runArgs.length === 0) {
|
|
221
|
+
// treat runCmd as a full command line when no args are provided
|
|
222
|
+
joined = String(runCmd || "");
|
|
223
|
+
} else {
|
|
224
|
+
joined = [runCmd, ...runArgs].map(shellQuote).join(" ");
|
|
225
|
+
}
|
|
226
|
+
spawnCmd = shell;
|
|
227
|
+
const shellMode = runShellInteractive ? "-lic" : "-lc";
|
|
228
|
+
spawnArgs = [shellMode, joined];
|
|
229
|
+
}
|
|
230
|
+
const resolvedCmd = resolveExecutable(spawnCmd);
|
|
231
|
+
return { spawnCmd: resolvedCmd, spawnArgs };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function spawnTerm(
|
|
235
|
+
runCmd,
|
|
236
|
+
runArgs,
|
|
237
|
+
runCwd,
|
|
238
|
+
runShell,
|
|
239
|
+
runShellInteractive,
|
|
240
|
+
colsOverride,
|
|
241
|
+
rowsOverride
|
|
242
|
+
) {
|
|
243
|
+
const { spawnCmd, spawnArgs } = buildSpawnCommand(runCmd, runArgs, runShell, runShellInteractive);
|
|
244
|
+
return pty.spawn(spawnCmd, spawnArgs, {
|
|
245
|
+
name: "xterm-256color",
|
|
246
|
+
cols: colsOverride || process.stdout.columns || ptyCols || 80,
|
|
247
|
+
rows: rowsOverride || process.stdout.rows || ptyRows || 24,
|
|
248
|
+
cwd: runCwd || process.cwd(),
|
|
249
|
+
env: process.env,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createSessionManager(options = {}) {
|
|
254
|
+
const {
|
|
255
|
+
spawn = spawnTerm,
|
|
256
|
+
stdout = noStdout ? null : process.stdout,
|
|
257
|
+
log = logStream,
|
|
258
|
+
idleLog = idleLogStream,
|
|
259
|
+
idleMs: idleMsOverride,
|
|
260
|
+
idleBurstMs: idleBurstMsOverride,
|
|
261
|
+
idlePacketMaxBytes: idlePacketMaxBytesOverride,
|
|
262
|
+
} = options;
|
|
263
|
+
const effectiveIdleMs = Number.isFinite(idleMsOverride) ? idleMsOverride : idleMs;
|
|
264
|
+
const effectiveIdleBurstMs = Number.isFinite(idleBurstMsOverride)
|
|
265
|
+
? idleBurstMsOverride
|
|
266
|
+
: idleBurstMs;
|
|
267
|
+
let effectiveIdlePacketMaxBytes = Number.isFinite(idlePacketMaxBytesOverride)
|
|
268
|
+
? idlePacketMaxBytesOverride
|
|
269
|
+
: idlePacketMaxBytes;
|
|
270
|
+
const sessions = new Map();
|
|
271
|
+
const MAX_CONVERSATION_EVENTS = 200;
|
|
272
|
+
|
|
273
|
+
function canonicalizeConversationText(value) {
|
|
274
|
+
const lines = String(value || "")
|
|
275
|
+
.split("\n")
|
|
276
|
+
.map((line) => line.trimEnd())
|
|
277
|
+
.map((line) => {
|
|
278
|
+
// Copilot spinner glyph changes frequently while content stays identical.
|
|
279
|
+
if (/^\s*[◐◑◒◓◎◉∙•●]\s+Thinking \(Esc to cancel/.test(line)) {
|
|
280
|
+
return line.replace(/^\s*[◐◑◒◓◎◉∙•●]\s+/, "Thinking ");
|
|
281
|
+
}
|
|
282
|
+
return line;
|
|
283
|
+
})
|
|
284
|
+
.filter(Boolean);
|
|
285
|
+
return lines.join("\n").replace(/\s+/g, " ").trim();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function emitSession(session, event) {
|
|
289
|
+
const payload = { ...event, sessionId: session.id };
|
|
290
|
+
session.hookEvents.push(payload);
|
|
291
|
+
if (session.hookEvents.length > MAX_HOOK_EVENTS) {
|
|
292
|
+
session.hookEvents.splice(0, session.hookEvents.length - MAX_HOOK_EVENTS);
|
|
293
|
+
}
|
|
294
|
+
session.hookRegistry.emit(payload);
|
|
295
|
+
for (const res of session.sseClients) {
|
|
296
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function idleStart(session) {
|
|
301
|
+
session.idleActive = true;
|
|
302
|
+
session.idleBuffer = "";
|
|
303
|
+
emitSession(session, { type: "thinking_start", ts: new Date().toISOString() });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function idleEnd(session) {
|
|
307
|
+
if (!session.idleActive) return;
|
|
308
|
+
session.idleActive = false;
|
|
309
|
+
emitSession(session, {
|
|
310
|
+
type: "thinking_end",
|
|
311
|
+
ts: new Date().toISOString(),
|
|
312
|
+
content: session.idleBuffer,
|
|
313
|
+
});
|
|
314
|
+
session.idleBuffer = "";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function broadcast(session, text) {
|
|
318
|
+
for (const ws of session.clients) {
|
|
319
|
+
if (ws.readyState === 1) {
|
|
320
|
+
ws.send(text);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function normalizeConversationText(text, { skipPromptCrop = false, profile = "none" } = {}) {
|
|
326
|
+
const stripped = stripAnsi(text || "");
|
|
327
|
+
const cleaned = stripped
|
|
328
|
+
.replace(/\r\n/g, "\n")
|
|
329
|
+
.replace(/\r/g, "\n")
|
|
330
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
331
|
+
.replace(/\[\?[0-9A-Za-z;?\\]+/g, "")
|
|
332
|
+
.trim();
|
|
333
|
+
const rawLines = cleaned.split("\n");
|
|
334
|
+
const lines = [];
|
|
335
|
+
let lastLine = "";
|
|
336
|
+
let lastPrompt = "";
|
|
337
|
+
let sincePrompt = 0;
|
|
338
|
+
const isSeparatorLine = (value) => /^[─━\-]+$/.test(value.replace(/\s+/g, ""));
|
|
339
|
+
for (const line of rawLines) {
|
|
340
|
+
const trimmed = line.replace(/[\u200B-\u200D\uFEFF]/g, "").trimEnd();
|
|
341
|
+
if (trimmed === lastLine) continue;
|
|
342
|
+
const isSeparator = isSeparatorLine(trimmed);
|
|
343
|
+
if (isSeparator && lastLine && /^[─━\-]+$/.test(lastLine.replace(/\s+/g, ""))) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const isPrompt = trimmed.includes("Type @ to mention files") || trimmed.startsWith("❯ ");
|
|
347
|
+
if (isPrompt) {
|
|
348
|
+
if (lastPrompt === trimmed && sincePrompt < 6) {
|
|
349
|
+
sincePrompt += 1;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
lastPrompt = trimmed;
|
|
353
|
+
sincePrompt = 0;
|
|
354
|
+
} else {
|
|
355
|
+
sincePrompt += 1;
|
|
356
|
+
}
|
|
357
|
+
lines.push(trimmed);
|
|
358
|
+
lastLine = trimmed;
|
|
359
|
+
}
|
|
360
|
+
let focused = lines;
|
|
361
|
+
if (!skipPromptCrop) {
|
|
362
|
+
const promptIdx = [];
|
|
363
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
364
|
+
const line = lines[i];
|
|
365
|
+
if (line.includes("Type @ to mention files") || line.startsWith("❯ ")) {
|
|
366
|
+
promptIdx.push(i);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
let start = 0;
|
|
370
|
+
if (promptIdx.length >= 2) {
|
|
371
|
+
start = promptIdx[promptIdx.length - 2];
|
|
372
|
+
} else if (promptIdx.length === 1) {
|
|
373
|
+
start = Math.max(0, promptIdx[0] - 2);
|
|
374
|
+
}
|
|
375
|
+
focused = lines.slice(start);
|
|
376
|
+
}
|
|
377
|
+
focused = filterConversationLines(focused, profile);
|
|
378
|
+
const maxLines = 120;
|
|
379
|
+
const sliced = focused.length > maxLines ? focused.slice(-maxLines) : focused;
|
|
380
|
+
const textOut = sliced.join("\n").trim();
|
|
381
|
+
const keyLines = sliced
|
|
382
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
383
|
+
.filter(
|
|
384
|
+
(l) =>
|
|
385
|
+
l &&
|
|
386
|
+
!l.includes("Type @ to mention files") &&
|
|
387
|
+
!l.startsWith("❯") &&
|
|
388
|
+
!/^[─━\-]+$/.test(l.replace(/\s+/g, ""))
|
|
389
|
+
);
|
|
390
|
+
const key = keyLines.join("\n");
|
|
391
|
+
return { text: textOut, key };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function diffLines(prevLines, nextLines) {
|
|
395
|
+
if (!prevLines || prevLines.length === 0) return nextLines;
|
|
396
|
+
const maxOverlap = Math.min(prevLines.length, nextLines.length);
|
|
397
|
+
for (let overlap = maxOverlap; overlap >= 1; overlap -= 1) {
|
|
398
|
+
const prevSlice = prevLines.slice(-overlap);
|
|
399
|
+
for (let i = 0; i <= nextLines.length - overlap; i += 1) {
|
|
400
|
+
let match = true;
|
|
401
|
+
for (let j = 0; j < overlap; j += 1) {
|
|
402
|
+
if (nextLines[i + j] !== prevSlice[j]) {
|
|
403
|
+
match = false;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (match) {
|
|
408
|
+
return nextLines.slice(i + overlap);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return nextLines;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getScreenDelta(session) {
|
|
416
|
+
if (!session.screen) return null;
|
|
417
|
+
const dirty = session.screen.consumeDirtyLines();
|
|
418
|
+
if (!dirty.length) return { delta: [] };
|
|
419
|
+
const ctx = Math.max(0, Number.isFinite(conversationContext) ? conversationContext : 0);
|
|
420
|
+
const rows = dirty.map((item) => item.row);
|
|
421
|
+
const minRow = Math.min(...rows);
|
|
422
|
+
const maxRow = Math.max(...rows);
|
|
423
|
+
const start = Math.max(0, minRow - ctx);
|
|
424
|
+
const end = Math.min(session.screen.rows - 1, maxRow + ctx);
|
|
425
|
+
const delta = [];
|
|
426
|
+
for (let r = start; r <= end; r += 1) {
|
|
427
|
+
const line = session.screen.getRow(r);
|
|
428
|
+
delta.push(line);
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
delta,
|
|
432
|
+
startRow: start,
|
|
433
|
+
endRow: end,
|
|
434
|
+
dirtyRows: rows.slice().sort((a, b) => a - b),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function setupHooks(session) {
|
|
439
|
+
session.hookRegistry.addHook((event) => {
|
|
440
|
+
if (event.type === "thinking_start") {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (event.type !== "thinking_end") return;
|
|
444
|
+
let normalized = { text: "", key: "" };
|
|
445
|
+
const screenDelta = getScreenDelta(session);
|
|
446
|
+
if (screenDelta && screenDelta.delta.length) {
|
|
447
|
+
normalized = normalizeConversationText(screenDelta.delta.join("\n"), {
|
|
448
|
+
skipPromptCrop: true,
|
|
449
|
+
profile: session.detectedProfile || session.conversationProfile || "none",
|
|
450
|
+
});
|
|
451
|
+
} else {
|
|
452
|
+
normalized = normalizeConversationText(event.content || "", {
|
|
453
|
+
profile: session.detectedProfile || session.conversationProfile || "none",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (!normalized.text) return;
|
|
457
|
+
addConversationEvent(session, {
|
|
458
|
+
role: "assistant",
|
|
459
|
+
markdown: normalized.text,
|
|
460
|
+
ts: new Date().toISOString(),
|
|
461
|
+
meta: {
|
|
462
|
+
source: "idle",
|
|
463
|
+
status: "final",
|
|
464
|
+
format: "terminal",
|
|
465
|
+
key: normalized.key,
|
|
466
|
+
rows:
|
|
467
|
+
screenDelta && Number.isFinite(screenDelta.startRow) && Number.isFinite(screenDelta.endRow)
|
|
468
|
+
? {
|
|
469
|
+
start: screenDelta.startRow + 1,
|
|
470
|
+
end: screenDelta.endRow + 1,
|
|
471
|
+
dirty: Array.isArray(screenDelta.dirtyRows)
|
|
472
|
+
? screenDelta.dirtyRows.map((r) => r + 1)
|
|
473
|
+
: [],
|
|
474
|
+
}
|
|
475
|
+
: null,
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
if (idleLog) {
|
|
480
|
+
session.hookRegistry.addHook((event) => {
|
|
481
|
+
if (idleMarkdown) {
|
|
482
|
+
const parts = formatMarkdownEvent(event, {
|
|
483
|
+
info: "text",
|
|
484
|
+
maxChars: idleMarkdownMaxChars,
|
|
485
|
+
clean: idleClean,
|
|
486
|
+
});
|
|
487
|
+
for (const part of parts) {
|
|
488
|
+
idleLog.write(part);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (event.type === "thinking_start") {
|
|
493
|
+
idleLog.write(`\n[IDLE_START] ${event.ts}\n`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (event.type === "thinking_end") {
|
|
497
|
+
if (event.content) idleLog.write(event.content);
|
|
498
|
+
idleLog.write(`\n[IDLE_END] ${event.ts}\n`);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (tgEnabled) {
|
|
504
|
+
session.hookRegistry.addHook((event) => {
|
|
505
|
+
if (event.type !== "thinking_end") return;
|
|
506
|
+
const parts = formatTelegramMarkdownEvent(event, { maxChars: tgMaxChars });
|
|
507
|
+
(async () => {
|
|
508
|
+
for (const text of parts) {
|
|
509
|
+
await sendWithRetry(
|
|
510
|
+
{
|
|
511
|
+
token: tgToken,
|
|
512
|
+
chatId: tgChat,
|
|
513
|
+
text,
|
|
514
|
+
parseMode: tgParseMode,
|
|
515
|
+
proxy: tgProxy,
|
|
516
|
+
timeoutMs: tgTimeoutMs,
|
|
517
|
+
},
|
|
518
|
+
tgRetry,
|
|
519
|
+
tgBackoffMs
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
})().catch(() => {
|
|
523
|
+
// ignore telegram errors in hook pipeline
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function cleanupSession(session, { kill } = { kill: true }) {
|
|
530
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
531
|
+
for (const ws of session.clients) {
|
|
532
|
+
try {
|
|
533
|
+
ws.close();
|
|
534
|
+
} catch {
|
|
535
|
+
// ignore
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const res of session.sseClients) {
|
|
539
|
+
try {
|
|
540
|
+
res.end();
|
|
541
|
+
} catch {
|
|
542
|
+
// ignore
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (kill && session.term) {
|
|
546
|
+
try {
|
|
547
|
+
session.term.kill();
|
|
548
|
+
} catch {
|
|
549
|
+
// ignore
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (session.inputLog) session.inputLog.end();
|
|
553
|
+
if (session.outputLog) session.outputLog.end();
|
|
554
|
+
logEvent(`session_cleanup id=${session.id} killed=${kill} pid=${session.pid ?? "n/a"}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function attachTerm(session, runCmd, runArgs, runCwd, runShell, runShellInteractive) {
|
|
558
|
+
const cols = session.initialCols || ptyCols;
|
|
559
|
+
const rows = session.initialRows || ptyRows;
|
|
560
|
+
const term = spawn(
|
|
561
|
+
runCmd || cmd,
|
|
562
|
+
runArgs || args,
|
|
563
|
+
runCwd,
|
|
564
|
+
runShell,
|
|
565
|
+
runShellInteractive,
|
|
566
|
+
cols,
|
|
567
|
+
rows
|
|
568
|
+
);
|
|
569
|
+
session.term = term;
|
|
570
|
+
session.pid = term.pid;
|
|
571
|
+
if (logDir) {
|
|
572
|
+
const inputLogPath = path.join(logDir, `session-${session.id}.input.log`);
|
|
573
|
+
const outputLogPath = path.join(logDir, `session-${session.id}.output.log`);
|
|
574
|
+
rotateLogFile(inputLogPath, {
|
|
575
|
+
maxBytes: logRotateMaxBytes,
|
|
576
|
+
retainDays: logRotateRetainDays,
|
|
577
|
+
});
|
|
578
|
+
rotateLogFile(outputLogPath, {
|
|
579
|
+
maxBytes: logRotateMaxBytes,
|
|
580
|
+
retainDays: logRotateRetainDays,
|
|
581
|
+
});
|
|
582
|
+
session.inputLog = fs.createWriteStream(
|
|
583
|
+
inputLogPath,
|
|
584
|
+
{ flags: "a" }
|
|
585
|
+
);
|
|
586
|
+
session.outputLog = fs.createWriteStream(
|
|
587
|
+
outputLogPath,
|
|
588
|
+
{ flags: "a" }
|
|
589
|
+
);
|
|
590
|
+
fs.writeFileSync(
|
|
591
|
+
path.join(logDir, `session-${session.id}.pid`),
|
|
592
|
+
String(session.pid || "")
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
session.writeInput = (data) => {
|
|
596
|
+
if (session.inputLog) session.inputLog.write(data);
|
|
597
|
+
emitSession(session, { type: "raw_input", ts: new Date().toISOString(), content: data });
|
|
598
|
+
term.write(data);
|
|
599
|
+
};
|
|
600
|
+
term.on("data", (data) => {
|
|
601
|
+
if (stdout) stdout.write(data);
|
|
602
|
+
if (log) log.write(data);
|
|
603
|
+
if (session.outputLog) session.outputLog.write(data);
|
|
604
|
+
if (session.screen) {
|
|
605
|
+
session.screen.write(data);
|
|
606
|
+
// Auto profile detection is disabled by default; users choose profile explicitly.
|
|
607
|
+
if (
|
|
608
|
+
session.conversationProfile === "auto" &&
|
|
609
|
+
!session.detectedProfile &&
|
|
610
|
+
detectProfileFromChunk(data, session.copilotDetectState)
|
|
611
|
+
) {
|
|
612
|
+
session.detectedProfile = "copilot";
|
|
613
|
+
addConversationEvent(session, {
|
|
614
|
+
role: "assistant",
|
|
615
|
+
markdown: "[conversation profile] GitHub Copilot CLI",
|
|
616
|
+
ts: new Date().toISOString(),
|
|
617
|
+
meta: { source: "system", format: "text" },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
session.lastOutputTs = Date.now();
|
|
622
|
+
emitSession(session, { type: "raw_output", ts: new Date().toISOString(), content: data });
|
|
623
|
+
const payload = webPlain ? stripAnsi(data) : data;
|
|
624
|
+
if (webEnabled) {
|
|
625
|
+
session.history.push(payload);
|
|
626
|
+
if (session.history.length > MAX_HISTORY) {
|
|
627
|
+
session.history.splice(0, session.history.length - MAX_HISTORY);
|
|
628
|
+
}
|
|
629
|
+
broadcast(session, payload);
|
|
630
|
+
}
|
|
631
|
+
if (!session.idleActive) {
|
|
632
|
+
idleStart(session);
|
|
633
|
+
}
|
|
634
|
+
if (session.idleTimer) {
|
|
635
|
+
clearTimeout(session.idleTimer);
|
|
636
|
+
}
|
|
637
|
+
const idleChunk = idleClean ? stripAnsi(data) : data;
|
|
638
|
+
session.idleBuffer += idleChunk;
|
|
639
|
+
if (Number.isFinite(idleBufferMaxChars) && idleBufferMaxChars > 0) {
|
|
640
|
+
if (session.idleBuffer.length > idleBufferMaxChars) {
|
|
641
|
+
session.idleBuffer = session.idleBuffer.slice(-idleBufferMaxChars);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const chunkLength = typeof data === "string" ? data.length : 0;
|
|
645
|
+
if (
|
|
646
|
+
Number.isFinite(chunkLength) &&
|
|
647
|
+
chunkLength > 0 &&
|
|
648
|
+
(!Number.isFinite(effectiveIdlePacketMaxBytes) || chunkLength > effectiveIdlePacketMaxBytes)
|
|
649
|
+
) {
|
|
650
|
+
effectiveIdlePacketMaxBytes = chunkLength;
|
|
651
|
+
}
|
|
652
|
+
let idleTimeout = effectiveIdleMs;
|
|
653
|
+
if (
|
|
654
|
+
Number.isFinite(effectiveIdlePacketMaxBytes) &&
|
|
655
|
+
effectiveIdlePacketMaxBytes > 0 &&
|
|
656
|
+
chunkLength > 0 &&
|
|
657
|
+
chunkLength < effectiveIdlePacketMaxBytes &&
|
|
658
|
+
Number.isFinite(effectiveIdleBurstMs) &&
|
|
659
|
+
effectiveIdleBurstMs > 0
|
|
660
|
+
) {
|
|
661
|
+
idleTimeout = Math.min(effectiveIdleMs, effectiveIdleBurstMs);
|
|
662
|
+
}
|
|
663
|
+
session.idleTimer = setTimeout(() => {
|
|
664
|
+
idleEnd(session);
|
|
665
|
+
}, idleTimeout);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
term.on("exit", (code) => {
|
|
669
|
+
cleanupSession(session, { kill: false });
|
|
670
|
+
session.term = null;
|
|
671
|
+
session.pid = null;
|
|
672
|
+
logEvent(`session_exit id=${session.id} code=${code ?? "n/a"}`);
|
|
673
|
+
if (session.exitOnClose) {
|
|
674
|
+
process.exit(code ?? 0);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function createSession({
|
|
680
|
+
id,
|
|
681
|
+
cmd: runCmd,
|
|
682
|
+
args: runArgs,
|
|
683
|
+
cwd: runCwd,
|
|
684
|
+
shell: runShell,
|
|
685
|
+
shellInteractive: runShellInteractive,
|
|
686
|
+
conversationProfile: runConversationProfile,
|
|
687
|
+
autoStart = true,
|
|
688
|
+
} = {}) {
|
|
689
|
+
const sessionId =
|
|
690
|
+
id || `s-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
691
|
+
if (sessions.has(sessionId)) return sessions.get(sessionId);
|
|
692
|
+
const session = {
|
|
693
|
+
id: sessionId,
|
|
694
|
+
term: null,
|
|
695
|
+
pid: null,
|
|
696
|
+
initialCols: ptyCols,
|
|
697
|
+
initialRows: ptyRows,
|
|
698
|
+
inputLog: null,
|
|
699
|
+
outputLog: null,
|
|
700
|
+
writeInput: null,
|
|
701
|
+
cmd: runCmd || cmd,
|
|
702
|
+
args: runArgs || args,
|
|
703
|
+
cwd: runCwd || process.cwd(),
|
|
704
|
+
shell: runShell === undefined ? undefined : Boolean(runShell),
|
|
705
|
+
shellInteractive:
|
|
706
|
+
runShellInteractive === undefined ? undefined : Boolean(runShellInteractive),
|
|
707
|
+
exitOnClose: false,
|
|
708
|
+
history: [],
|
|
709
|
+
clients: new Set(),
|
|
710
|
+
sseClients: new Set(),
|
|
711
|
+
hookRegistry: createHookRegistry({
|
|
712
|
+
onError(err, event) {
|
|
713
|
+
session.hookErrors.push({
|
|
714
|
+
ts: new Date().toISOString(),
|
|
715
|
+
message: err?.message || "hook error",
|
|
716
|
+
type: event?.type || "unknown",
|
|
717
|
+
});
|
|
718
|
+
if (session.hookErrors.length > MAX_HOOK_EVENTS) {
|
|
719
|
+
session.hookErrors.splice(0, session.hookErrors.length - MAX_HOOK_EVENTS);
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
}),
|
|
723
|
+
hookEvents: [],
|
|
724
|
+
hookErrors: [],
|
|
725
|
+
conversation: [],
|
|
726
|
+
conversationSeq: 0,
|
|
727
|
+
screen: new TerminalBuffer(ptyCols, ptyRows),
|
|
728
|
+
lastScreenLines: [],
|
|
729
|
+
conversationProfile: (() => {
|
|
730
|
+
const raw = String(runConversationProfile || conversationProfile || "none").toLowerCase();
|
|
731
|
+
if (raw === "off" || raw === "auto") return "none";
|
|
732
|
+
if (raw !== "copilot") return "none";
|
|
733
|
+
return raw;
|
|
734
|
+
})(),
|
|
735
|
+
detectedProfile: "",
|
|
736
|
+
copilotDetectState: {
|
|
737
|
+
seenVersion: false,
|
|
738
|
+
seenPrompt: false,
|
|
739
|
+
},
|
|
740
|
+
idleTimer: null,
|
|
741
|
+
idleBuffer: "",
|
|
742
|
+
idleActive: false,
|
|
743
|
+
lastOutputTs: null,
|
|
744
|
+
createdAt: Date.now(),
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
setupHooks(session);
|
|
748
|
+
if (session.conversationProfile && session.conversationProfile !== "none") {
|
|
749
|
+
addConversationEvent(session, {
|
|
750
|
+
role: "assistant",
|
|
751
|
+
markdown: `[conversation profile] ${session.conversationProfile}`,
|
|
752
|
+
ts: new Date().toISOString(),
|
|
753
|
+
meta: { source: "system", format: "text" },
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (autoStart) {
|
|
757
|
+
session.exitOnClose = session.id === DEFAULT_SESSION_ID && !noDefaultSession;
|
|
758
|
+
attachTerm(
|
|
759
|
+
session,
|
|
760
|
+
session.cmd,
|
|
761
|
+
session.args,
|
|
762
|
+
session.cwd,
|
|
763
|
+
session.shell,
|
|
764
|
+
session.shellInteractive
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
sessions.set(sessionId, session);
|
|
769
|
+
logEvent(
|
|
770
|
+
`session_create id=${session.id} autoStart=${autoStart} cmd=${session.cmd} cwd=${session.cwd}`
|
|
771
|
+
);
|
|
772
|
+
return session;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function addConversationEvent(session, event) {
|
|
776
|
+
if (!session) return;
|
|
777
|
+
const entry = {
|
|
778
|
+
seq: (session.conversationSeq || 0) + 1,
|
|
779
|
+
role: event.role || "assistant",
|
|
780
|
+
markdown: event.markdown || "",
|
|
781
|
+
ts: event.ts || new Date().toISOString(),
|
|
782
|
+
sessionId: session.id,
|
|
783
|
+
meta: event.meta || null,
|
|
784
|
+
};
|
|
785
|
+
if (entry.role === "assistant" && entry.meta?.format === "terminal") {
|
|
786
|
+
const last = session.conversation[session.conversation.length - 1];
|
|
787
|
+
const normalizeBlock = (value) =>
|
|
788
|
+
String(value || "")
|
|
789
|
+
.replace(/\s+/g, " ")
|
|
790
|
+
.trim();
|
|
791
|
+
const normalizeLines = (value) =>
|
|
792
|
+
String(value || "")
|
|
793
|
+
.split("\n")
|
|
794
|
+
.map((line) => line.trim())
|
|
795
|
+
.filter(Boolean);
|
|
796
|
+
if (
|
|
797
|
+
last &&
|
|
798
|
+
last.role === "assistant" &&
|
|
799
|
+
last.meta?.format === "terminal" &&
|
|
800
|
+
(last.meta?.key && entry.meta?.key && last.meta.key === entry.meta.key)
|
|
801
|
+
) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const lastText = last ? normalizeBlock(last.markdown) : "";
|
|
805
|
+
const nextText = normalizeBlock(entry.markdown);
|
|
806
|
+
if (lastText && nextText && lastText.includes(nextText)) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (last?.meta?.key && entry.meta?.key) {
|
|
810
|
+
const lastLines = normalizeLines(last.meta.key);
|
|
811
|
+
const nextLines = normalizeLines(entry.meta.key);
|
|
812
|
+
if (lastLines.length && nextLines.length) {
|
|
813
|
+
const lastSet = new Set(lastLines);
|
|
814
|
+
const isSubset = nextLines.every((line) => lastSet.has(line));
|
|
815
|
+
if (isSubset) return;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (entry.meta?.key && entry.meta.key.length < 8) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const normalized = normalizeBlock(entry.markdown);
|
|
822
|
+
if (normalized && session.lastConversationText === normalized) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const canonical = canonicalizeConversationText(entry.markdown);
|
|
826
|
+
if (canonical && session.lastConversationCanonical === canonical) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
session.lastConversationText = normalized || session.lastConversationText;
|
|
830
|
+
session.lastConversationCanonical = canonical || session.lastConversationCanonical;
|
|
831
|
+
session.lastConversationLines = normalizeLines(event.markdown);
|
|
832
|
+
}
|
|
833
|
+
session.conversationSeq = entry.seq;
|
|
834
|
+
session.conversation.push(entry);
|
|
835
|
+
if (session.conversation.length > MAX_CONVERSATION_EVENTS) {
|
|
836
|
+
session.conversation.splice(0, session.conversation.length - MAX_CONVERSATION_EVENTS);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function getSession(id) {
|
|
841
|
+
return sessions.get(id);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function getDefaultSession() {
|
|
845
|
+
return sessions.get(DEFAULT_SESSION_ID);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function listSessions() {
|
|
849
|
+
return Array.from(sessions.values()).map((session) => ({
|
|
850
|
+
id: session.id,
|
|
851
|
+
started: Boolean(session.term),
|
|
852
|
+
cmd: session.cmd,
|
|
853
|
+
args: session.args,
|
|
854
|
+
cwd: session.cwd,
|
|
855
|
+
cols: session.term?.cols || session.initialCols || ptyCols,
|
|
856
|
+
rows: session.term?.rows || session.initialRows || ptyRows,
|
|
857
|
+
initialCols: session.initialCols || ptyCols,
|
|
858
|
+
initialRows: session.initialRows || ptyRows,
|
|
859
|
+
idleActive: session.idleActive,
|
|
860
|
+
lastOutputTs: session.lastOutputTs,
|
|
861
|
+
createdAt: session.createdAt,
|
|
862
|
+
hookErrorCount: session.hookErrors.length,
|
|
863
|
+
conversationProfile: session.detectedProfile || session.conversationProfile || "none",
|
|
864
|
+
}));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function deleteSession(id) {
|
|
868
|
+
const session = sessions.get(id);
|
|
869
|
+
if (!session) return false;
|
|
870
|
+
sessions.delete(id);
|
|
871
|
+
cleanupSession(session, { kill: true });
|
|
872
|
+
logEvent(`session_delete id=${id}`);
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function startSession(
|
|
877
|
+
id,
|
|
878
|
+
{
|
|
879
|
+
cmd: runCmd,
|
|
880
|
+
args: runArgs,
|
|
881
|
+
cwd: runCwd,
|
|
882
|
+
shell: runShell,
|
|
883
|
+
shellInteractive: runShellInteractive,
|
|
884
|
+
conversationProfile: runConversationProfile,
|
|
885
|
+
} = {}
|
|
886
|
+
) {
|
|
887
|
+
const session = sessions.get(id);
|
|
888
|
+
if (!session) return null;
|
|
889
|
+
if (session.term) return session;
|
|
890
|
+
session.cmd = runCmd || session.cmd || cmd;
|
|
891
|
+
session.args = runArgs || session.args || args;
|
|
892
|
+
session.cwd = runCwd || session.cwd || process.cwd();
|
|
893
|
+
if (runShell !== undefined) {
|
|
894
|
+
session.shell = Boolean(runShell);
|
|
895
|
+
}
|
|
896
|
+
if (runShellInteractive !== undefined) {
|
|
897
|
+
session.shellInteractive = Boolean(runShellInteractive);
|
|
898
|
+
}
|
|
899
|
+
if (runConversationProfile !== undefined) {
|
|
900
|
+
const raw = String(runConversationProfile || "").toLowerCase() || "none";
|
|
901
|
+
session.conversationProfile =
|
|
902
|
+
raw === "copilot" ? "copilot" : "none";
|
|
903
|
+
session.detectedProfile = "";
|
|
904
|
+
session.copilotDetectState = {
|
|
905
|
+
seenVersion: false,
|
|
906
|
+
seenPrompt: false,
|
|
907
|
+
};
|
|
908
|
+
if (session.conversationProfile !== "none") {
|
|
909
|
+
addConversationEvent(session, {
|
|
910
|
+
role: "assistant",
|
|
911
|
+
markdown: `[conversation profile] ${session.conversationProfile}`,
|
|
912
|
+
ts: new Date().toISOString(),
|
|
913
|
+
meta: { source: "system", format: "text" },
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
attachTerm(
|
|
918
|
+
session,
|
|
919
|
+
session.cmd,
|
|
920
|
+
session.args,
|
|
921
|
+
session.cwd,
|
|
922
|
+
session.shell,
|
|
923
|
+
session.shellInteractive
|
|
924
|
+
);
|
|
925
|
+
logEvent(`session_start id=${session.id} cmd=${session.cmd} cwd=${session.cwd}`);
|
|
926
|
+
return session;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let didShutdown = false;
|
|
930
|
+
function shutdown() {
|
|
931
|
+
if (didShutdown) return;
|
|
932
|
+
didShutdown = true;
|
|
933
|
+
for (const session of sessions.values()) {
|
|
934
|
+
cleanupSession(session, { kill: true });
|
|
935
|
+
}
|
|
936
|
+
sessions.clear();
|
|
937
|
+
logEvent("shutdown");
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (!noDefaultSession) {
|
|
941
|
+
createSession({ id: DEFAULT_SESSION_ID, autoStart: true });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
createSession,
|
|
946
|
+
getSession,
|
|
947
|
+
getDefaultSession,
|
|
948
|
+
listSessions,
|
|
949
|
+
deleteSession,
|
|
950
|
+
startSession,
|
|
951
|
+
addConversationEvent,
|
|
952
|
+
shutdown,
|
|
953
|
+
sessions,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const sessionManager = isCli ? createSessionManager() : null;
|
|
958
|
+
|
|
959
|
+
function restoreStdin() {
|
|
960
|
+
if (process.stdin.isTTY) {
|
|
961
|
+
try {
|
|
962
|
+
process.stdin.setRawMode(false);
|
|
963
|
+
} catch {
|
|
964
|
+
// ignore
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (process.stdin.isTTY && process.platform !== "win32") {
|
|
968
|
+
try {
|
|
969
|
+
execSync("stty sane", { stdio: "ignore" });
|
|
970
|
+
} catch {
|
|
971
|
+
// ignore
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
module.exports = {
|
|
977
|
+
createSessionManager,
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
if (isCli) {
|
|
981
|
+
if (!noStdin) {
|
|
982
|
+
if (process.stdin.isTTY) {
|
|
983
|
+
process.stdin.setRawMode(true);
|
|
984
|
+
}
|
|
985
|
+
process.stdin.resume();
|
|
986
|
+
process.stdin.on("data", (data) => {
|
|
987
|
+
if (data && data.includes && data.includes("\u0003")) {
|
|
988
|
+
sessionManager.shutdown();
|
|
989
|
+
restoreStdin();
|
|
990
|
+
process.exit(0);
|
|
991
|
+
}
|
|
992
|
+
const session = sessionManager.getDefaultSession();
|
|
993
|
+
if (session && session.term) {
|
|
994
|
+
if (session.writeInput) session.writeInput(data);
|
|
995
|
+
else session.term.write(data);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
process.on("SIGINT", () => {
|
|
1001
|
+
sessionManager.shutdown();
|
|
1002
|
+
restoreStdin();
|
|
1003
|
+
process.exit(0);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
process.stdout.on("resize", () => {
|
|
1007
|
+
for (const session of sessionManager.sessions.values()) {
|
|
1008
|
+
if (session.term) {
|
|
1009
|
+
const cols = process.stdout.columns || 80;
|
|
1010
|
+
const rows = process.stdout.rows || 24;
|
|
1011
|
+
session.term.resize(cols, rows);
|
|
1012
|
+
if (session.screen) session.screen.resize(cols, rows);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
let webServer;
|
|
1019
|
+
let apiServer;
|
|
1020
|
+
let wss;
|
|
1021
|
+
|
|
1022
|
+
if (isCli && (webEnabled || apiEnabled)) {
|
|
1023
|
+
const isLoopbackHost =
|
|
1024
|
+
host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
1025
|
+
logEvent(`Version: v${appVersion}`);
|
|
1026
|
+
if (authToken) {
|
|
1027
|
+
logEvent("Auth: enabled (token required)");
|
|
1028
|
+
}
|
|
1029
|
+
logEvent(`Use X-Forwarded-For: ${useXForwardedFor ? "enabled" : "disabled"}`);
|
|
1030
|
+
if (!authToken) {
|
|
1031
|
+
logEvent("Auth: disabled");
|
|
1032
|
+
logEvent(
|
|
1033
|
+
"WARNING: AUTH_TOKEN not set; API/WS are unauthenticated. Use --auth-token or AUTH_TOKEN env."
|
|
1034
|
+
);
|
|
1035
|
+
if (!isLoopbackHost) {
|
|
1036
|
+
logEvent(
|
|
1037
|
+
`WARNING: host=${host} is not loopback; unauthenticated API/WS may be exposed.`
|
|
1038
|
+
);
|
|
1039
|
+
if (enforceAuthOnPublic) {
|
|
1040
|
+
logEvent(
|
|
1041
|
+
"FATAL: --enforce-auth-on-public is enabled and AUTH_TOKEN is missing for non-loopback host."
|
|
1042
|
+
);
|
|
1043
|
+
process.exit(1);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
const apiRouter = createApiRouter({
|
|
1048
|
+
sessionManager,
|
|
1049
|
+
idleMs: () => idleMs,
|
|
1050
|
+
webEnabled: () => webEnabled,
|
|
1051
|
+
apiEnabled: () => apiEnabled,
|
|
1052
|
+
version: appVersion,
|
|
1053
|
+
user: os.userInfo().username,
|
|
1054
|
+
authToken,
|
|
1055
|
+
useXForwardedFor,
|
|
1056
|
+
accessLog: logAccess,
|
|
1057
|
+
accessLogMode,
|
|
1058
|
+
});
|
|
1059
|
+
const publicDir = path.join(__dirname, "public");
|
|
1060
|
+
|
|
1061
|
+
if (webEnabled) {
|
|
1062
|
+
const webApp = express();
|
|
1063
|
+
webApp.use(express.json({ limit: "1mb" }));
|
|
1064
|
+
webApp.use(express.static(publicDir));
|
|
1065
|
+
if (apiEnabled && apiPort === port) {
|
|
1066
|
+
webApp.use(apiRouter);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
webServer = http.createServer(webApp);
|
|
1070
|
+
wss = attachWsServer({
|
|
1071
|
+
server: webServer,
|
|
1072
|
+
sessionManager,
|
|
1073
|
+
debugWs,
|
|
1074
|
+
debugStream,
|
|
1075
|
+
authToken,
|
|
1076
|
+
useXForwardedFor,
|
|
1077
|
+
accessLog: logAccess,
|
|
1078
|
+
accessLogMode,
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
webServer.listen(port, host, () => {
|
|
1082
|
+
logEvent(`Web UI listening on http://${host}:${port}`);
|
|
1083
|
+
if (apiEnabled && apiPort === port) {
|
|
1084
|
+
logEvent(`API listening on http://${host}:${port}/api`);
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (apiEnabled && (!webEnabled || apiPort !== port)) {
|
|
1090
|
+
const apiApp = express();
|
|
1091
|
+
apiApp.use(express.json({ limit: "1mb" }));
|
|
1092
|
+
apiApp.use(apiRouter);
|
|
1093
|
+
apiServer = http.createServer(apiApp);
|
|
1094
|
+
apiServer.listen(apiPort, host, () => {
|
|
1095
|
+
logEvent(`API listening on http://${host}:${apiPort}/api`);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (isCli) {
|
|
1101
|
+
process.on("exit", () => {
|
|
1102
|
+
sessionManager.shutdown();
|
|
1103
|
+
restoreStdin();
|
|
1104
|
+
if (logStream) logStream.end();
|
|
1105
|
+
if (accessLogStream) accessLogStream.end();
|
|
1106
|
+
if (idleLogStream) idleLogStream.end();
|
|
1107
|
+
if (debugStream && debugStream !== process.stdout) {
|
|
1108
|
+
debugStream.end();
|
|
1109
|
+
}
|
|
1110
|
+
if (wss) wss.close();
|
|
1111
|
+
if (webServer) webServer.close();
|
|
1112
|
+
if (apiServer) apiServer.close();
|
|
1113
|
+
});
|
|
1114
|
+
}
|