termbeam 1.19.4 → 1.20.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 +16 -0
- package/package.json +1 -1
- package/public/assets/{_basePickBy-D2x9UR-Z.js → _basePickBy-CsEFpeup.js} +1 -1
- package/public/assets/{_baseUniq-C01jsmVS.js → _baseUniq-ZNfm73QS.js} +1 -1
- package/public/assets/{arc-t3uO9VFT.js → arc-89CrHVpy.js} +1 -1
- package/public/assets/{architectureDiagram-Q4EWVU46-DWUIuXit.js → architectureDiagram-Q4EWVU46-oOX9WpjV.js} +1 -1
- package/public/assets/{blockDiagram-DXYQGD6D-DnP4lNOB.js → blockDiagram-DXYQGD6D-Dv7Qlwwa.js} +1 -1
- package/public/assets/{c4Diagram-AHTNJAMY-B29P8b7E.js → c4Diagram-AHTNJAMY-DH6nUY9v.js} +1 -1
- package/public/assets/channel-1hhrLuQG.js +1 -0
- package/public/assets/{chunk-4BX2VUAB-BH7Ixc1K.js → chunk-4BX2VUAB-br_MLFTb.js} +1 -1
- package/public/assets/{chunk-4TB4RGXK-h7uQ9ZtR.js → chunk-4TB4RGXK-D6RT0VpP.js} +1 -1
- package/public/assets/{chunk-55IACEB6-D9ZHEhWx.js → chunk-55IACEB6-B6WHKDLC.js} +1 -1
- package/public/assets/{chunk-EDXVE4YY-BEKltVR7.js → chunk-EDXVE4YY-CfyEh_gx.js} +1 -1
- package/public/assets/{chunk-FMBD7UC4-BPkcv-bj.js → chunk-FMBD7UC4-IxUFwHkQ.js} +1 -1
- package/public/assets/{chunk-OYMX7WX6-C-wnBny1.js → chunk-OYMX7WX6-BuoXiEMH.js} +1 -1
- package/public/assets/{chunk-QZHKN3VN-DBZnU2yp.js → chunk-QZHKN3VN-gwjweP0s.js} +1 -1
- package/public/assets/{chunk-YZCP3GAM-C8GNavGc.js → chunk-YZCP3GAM-AxzesjLV.js} +1 -1
- package/public/assets/classDiagram-6PBFFD2Q-BCCxmX4-.js +1 -0
- package/public/assets/classDiagram-v2-HSJHXN6E-BCCxmX4-.js +1 -0
- package/public/assets/clone-Zlu95tC9.js +1 -0
- package/public/assets/{cose-bilkent-S5V4N54A-BeFh7BYc.js → cose-bilkent-S5V4N54A-DA0VKF4u.js} +1 -1
- package/public/assets/{dagre-KV5264BT-DlsYCBSj.js → dagre-KV5264BT-TROeRxkl.js} +1 -1
- package/public/assets/{diagram-5BDNPKRD-CnTlMSc9.js → diagram-5BDNPKRD-Cy0d3UWt.js} +1 -1
- package/public/assets/{diagram-G4DWMVQ6-CKODi7zI.js → diagram-G4DWMVQ6-DLqyiLD9.js} +1 -1
- package/public/assets/{diagram-MMDJMWI5-DEJGgmOX.js → diagram-MMDJMWI5-COX5ltag.js} +1 -1
- package/public/assets/{diagram-TYMM5635-Dju-tIVS.js → diagram-TYMM5635-jNMi-Wxw.js} +1 -1
- package/public/assets/{erDiagram-SMLLAGMA-CqPQSqot.js → erDiagram-SMLLAGMA-DPNWOmMF.js} +1 -1
- package/public/assets/{flowDiagram-DWJPFMVM-BeIRzZQp.js → flowDiagram-DWJPFMVM-CtEH78aJ.js} +1 -1
- package/public/assets/{ganttDiagram-T4ZO3ILL-B6BnA7VR.js → ganttDiagram-T4ZO3ILL-DnmNU9Yo.js} +1 -1
- package/public/assets/{gitGraphDiagram-UUTBAWPF-BoSi7fJX.js → gitGraphDiagram-UUTBAWPF-BEBkdt6e.js} +1 -1
- package/public/assets/{graph-uVutBrOm.js → graph-BrV-Z5FX.js} +1 -1
- package/public/assets/index-BBM5qWeb.js +455 -0
- package/public/assets/index-C2HbSpIZ.css +32 -0
- package/public/assets/{infoDiagram-42DDH7IO-DD-KdApo.js → infoDiagram-42DDH7IO-5B0oQBTo.js} +1 -1
- package/public/assets/{ishikawaDiagram-UXIWVN3A-D36iFaUH.js → ishikawaDiagram-UXIWVN3A-JvObAFoj.js} +1 -1
- package/public/assets/{journeyDiagram-VCZTEJTY-BMQDm-H-.js → journeyDiagram-VCZTEJTY-B4x5Q9FT.js} +1 -1
- package/public/assets/{kanban-definition-6JOO6SKY-D1FZXkK7.js → kanban-definition-6JOO6SKY-BJnqk1Pz.js} +1 -1
- package/public/assets/{layout-xVUStQT2.js → layout-CFi5eoUP.js} +1 -1
- package/public/assets/{linear-BTv56PNK.js → linear-BdauaB9G.js} +1 -1
- package/public/assets/{mindmap-definition-QFDTVHPH-CvhBJGrR.js → mindmap-definition-QFDTVHPH-B5a5e6e4.js} +1 -1
- package/public/assets/{pieDiagram-DEJITSTG-DcxBOIJ2.js → pieDiagram-DEJITSTG-BasykeiV.js} +1 -1
- package/public/assets/{quadrantDiagram-34T5L4WZ-D79TxdrP.js → quadrantDiagram-34T5L4WZ-BpIn9pue.js} +1 -1
- package/public/assets/{requirementDiagram-MS252O5E-gOOiR6tu.js → requirementDiagram-MS252O5E-bScQOYzc.js} +1 -1
- package/public/assets/{sankeyDiagram-XADWPNL6-YUncdO2g.js → sankeyDiagram-XADWPNL6-CdwsbfFQ.js} +1 -1
- package/public/assets/{sequenceDiagram-FGHM5R23-eoBFRqV1.js → sequenceDiagram-FGHM5R23-BLSeDdFS.js} +1 -1
- package/public/assets/{stateDiagram-FHFEXIEX-DeQeLuN0.js → stateDiagram-FHFEXIEX-DwQJYdc7.js} +1 -1
- package/public/assets/stateDiagram-v2-QKLJ7IA2-BxWrp5cC.js +1 -0
- package/public/assets/{timeline-definition-GMOUNBTQ-CV0p2TOx.js → timeline-definition-GMOUNBTQ-BkLAz5O2.js} +1 -1
- package/public/assets/{vennDiagram-DHZGUBPP-CciIt7hk.js → vennDiagram-DHZGUBPP-DiiYjk-t.js} +1 -1
- package/public/assets/{wardley-RL74JXVD-DpAn0g0p.js → wardley-RL74JXVD-BsijixfH.js} +1 -1
- package/public/assets/{wardleyDiagram-NUSXRM2D-BcEpTQV4.js → wardleyDiagram-NUSXRM2D-DZ7LhIvm.js} +1 -1
- package/public/assets/{xychartDiagram-5P7HB3ND-B-PklpIN.js → xychartDiagram-5P7HB3ND-CJNPkFhM.js} +1 -1
- package/public/index.html +2 -2
- package/public/sw.js +1 -1
- package/src/server/copilot-sdk.js +617 -0
- package/src/server/index.js +11 -2
- package/src/server/routes.js +440 -10
- package/src/server/sessions.js +6 -0
- package/src/server/websocket.js +175 -2
- package/public/assets/channel-Du2155FM.js +0 -1
- package/public/assets/classDiagram-6PBFFD2Q-Dzf6e5xB.js +0 -1
- package/public/assets/classDiagram-v2-HSJHXN6E-Dzf6e5xB.js +0 -1
- package/public/assets/clone-VT9_rs7L.js +0 -1
- package/public/assets/index-C0J_Dxjj.css +0 -32
- package/public/assets/index-NvPavSM9.js +0 -447
- package/public/assets/stateDiagram-v2-QKLJ7IA2-BhqrHPnX.js +0 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Optional dependency — not available in CI or when not installed
|
|
4
|
+
let CopilotClient, approveAll;
|
|
5
|
+
try {
|
|
6
|
+
({ CopilotClient, approveAll } = require('@github/copilot-sdk'));
|
|
7
|
+
} catch {
|
|
8
|
+
// SDK not installed — CopilotService will be unavailable
|
|
9
|
+
}
|
|
10
|
+
const log = require('../utils/logger');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
|
|
15
|
+
const configDir = process.env.TERMBEAM_CONFIG_DIR || path.join(os.homedir(), '.termbeam');
|
|
16
|
+
const AUTH_URL_FILE = path.join(configDir, 'auth-urls.log');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a browser handler script that writes URLs to a file
|
|
20
|
+
* instead of opening a browser. This lets us forward OAuth URLs to mobile clients.
|
|
21
|
+
* Cross-platform: generates .cmd on Windows, .sh elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
function ensureBrowserHandler() {
|
|
24
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const ext = process.platform === 'win32' ? '.cmd' : '.sh';
|
|
26
|
+
const handlerPath = path.join(configDir, `browser-handler${ext}`);
|
|
27
|
+
const script =
|
|
28
|
+
process.platform === 'win32'
|
|
29
|
+
? `@echo off\r\necho %1 >> "${AUTH_URL_FILE}"\r\n`
|
|
30
|
+
: `#!/bin/sh\necho "$1" >> "${AUTH_URL_FILE}"\n`;
|
|
31
|
+
const mode = process.platform === 'win32' ? 0o700 : 0o755;
|
|
32
|
+
try {
|
|
33
|
+
fs.writeFileSync(handlerPath, script, { mode });
|
|
34
|
+
} catch {
|
|
35
|
+
// Ignore — fallback to BROWSER=echo
|
|
36
|
+
}
|
|
37
|
+
return handlerPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read MCP server configs from the user's Copilot config directory.
|
|
42
|
+
* Injects BROWSER env var into each local server to intercept OAuth flows.
|
|
43
|
+
*/
|
|
44
|
+
function loadMcpConfig(browserHandler) {
|
|
45
|
+
const configDir = path.join(os.homedir(), '.copilot');
|
|
46
|
+
const mcpPath = path.join(configDir, 'mcp-config.json');
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(mcpPath, 'utf8');
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
const servers = parsed.mcpServers || null;
|
|
51
|
+
if (!servers) return null;
|
|
52
|
+
// Inject BROWSER handler into each local MCP server's env
|
|
53
|
+
for (const [, config] of Object.entries(servers)) {
|
|
54
|
+
if (!config || typeof config !== 'object') continue;
|
|
55
|
+
if (!config.type || config.type === 'local' || config.type === 'stdio') {
|
|
56
|
+
config.env = { ...config.env, BROWSER: browserHandler };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return servers;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class CopilotService {
|
|
66
|
+
constructor() {
|
|
67
|
+
this.client = null;
|
|
68
|
+
this.sessions = new Map(); // sessionId -> { session, listeners }
|
|
69
|
+
this._startPromise = null;
|
|
70
|
+
this.browserHandler = ensureBrowserHandler();
|
|
71
|
+
this.mcpServers = loadMcpConfig(this.browserHandler);
|
|
72
|
+
if (this.mcpServers) {
|
|
73
|
+
const count = Object.keys(this.mcpServers).length;
|
|
74
|
+
log.info(`Loaded ${count} MCP server(s) from ~/.copilot/mcp-config.json`);
|
|
75
|
+
}
|
|
76
|
+
this._authUrlOffset = 0;
|
|
77
|
+
this._watchAuthUrls();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Watch the auth URL file for new OAuth URLs written by MCP servers.
|
|
82
|
+
* Forward them to all active WebSocket listeners.
|
|
83
|
+
*/
|
|
84
|
+
_watchAuthUrls() {
|
|
85
|
+
// Truncate file on startup
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(AUTH_URL_FILE, '');
|
|
88
|
+
} catch {
|
|
89
|
+
/* ignore */
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this._authPollTimer = setInterval(() => {
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(AUTH_URL_FILE, 'utf8');
|
|
95
|
+
if (content.length <= this._authUrlOffset) return;
|
|
96
|
+
const newContent = content.slice(this._authUrlOffset);
|
|
97
|
+
this._authUrlOffset = content.length;
|
|
98
|
+
const urls = newContent.split('\n').filter((u) => u.trim().startsWith('http'));
|
|
99
|
+
for (const url of urls) {
|
|
100
|
+
try {
|
|
101
|
+
log.info(`Auth URL intercepted: ${new URL(url).origin}/...`);
|
|
102
|
+
} catch {
|
|
103
|
+
log.info('Auth URL intercepted');
|
|
104
|
+
}
|
|
105
|
+
const event = { type: 'copilot.auth_url', data: { url: url.trim() } };
|
|
106
|
+
// Forward to ALL active session listeners
|
|
107
|
+
for (const [, entry] of this.sessions) {
|
|
108
|
+
if (entry.listener) entry.listener(event);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* file may not exist yet */
|
|
113
|
+
}
|
|
114
|
+
}, 500);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async ensureClient() {
|
|
118
|
+
if (this.client) return this.client;
|
|
119
|
+
if (this._startPromise) return this._startPromise;
|
|
120
|
+
this._startPromise = this._doStart();
|
|
121
|
+
return this._startPromise;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _doStart() {
|
|
125
|
+
try {
|
|
126
|
+
if (!CopilotClient) throw new Error('Copilot SDK not installed');
|
|
127
|
+
process.env.BROWSER = this.browserHandler;
|
|
128
|
+
this.client = new CopilotClient({
|
|
129
|
+
cwd: process.cwd(),
|
|
130
|
+
});
|
|
131
|
+
await this.client.start();
|
|
132
|
+
log.info('Copilot SDK client started');
|
|
133
|
+
return this.client;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
log.error('Failed to start Copilot SDK client:', err.message);
|
|
136
|
+
this.client = null;
|
|
137
|
+
if (this._authPollTimer) {
|
|
138
|
+
clearInterval(this._authPollTimer);
|
|
139
|
+
this._authPollTimer = null;
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
} finally {
|
|
143
|
+
this._startPromise = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Custom permission handler that intercepts URL requests (OAuth flows)
|
|
149
|
+
* and forwards them to the WebSocket client instead of opening a server-side browser.
|
|
150
|
+
*/
|
|
151
|
+
_createPermissionHandler(sessionId, eventBuffer) {
|
|
152
|
+
return (request) => {
|
|
153
|
+
if (request.kind === 'url' && request.url) {
|
|
154
|
+
log.info(`Auth URL requested for session ${sessionId}`);
|
|
155
|
+
const entry = this.sessions.get(sessionId);
|
|
156
|
+
const event = {
|
|
157
|
+
type: 'copilot.auth_url',
|
|
158
|
+
data: { url: request.url },
|
|
159
|
+
};
|
|
160
|
+
if (entry?.listener) {
|
|
161
|
+
entry.listener(event);
|
|
162
|
+
} else if (entry) {
|
|
163
|
+
entry.eventBuffer.push(event);
|
|
164
|
+
} else if (eventBuffer) {
|
|
165
|
+
// Buffer pre-registration events until session entry exists
|
|
166
|
+
eventBuffer.push(event);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { kind: 'approved' };
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async resumeSession(sdkSessionId, options = {}) {
|
|
174
|
+
const client = await this.ensureClient();
|
|
175
|
+
const sessionId = require('crypto').randomBytes(16).toString('hex');
|
|
176
|
+
|
|
177
|
+
const eventBuffer = [];
|
|
178
|
+
|
|
179
|
+
const resumeCwd = options.cwd || process.cwd();
|
|
180
|
+
const configDir = path.join(os.homedir(), '.copilot');
|
|
181
|
+
|
|
182
|
+
const resumeConfig = {
|
|
183
|
+
onPermissionRequest: this._createPermissionHandler(sessionId, eventBuffer),
|
|
184
|
+
streaming: true,
|
|
185
|
+
workingDirectory: resumeCwd,
|
|
186
|
+
configDir,
|
|
187
|
+
onUserInputRequest: async (request) => {
|
|
188
|
+
const event = {
|
|
189
|
+
type: 'copilot.user_input_request',
|
|
190
|
+
data: {
|
|
191
|
+
question: request.question,
|
|
192
|
+
choices: request.choices,
|
|
193
|
+
allowFreeform: request.allowFreeform !== false,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
const entry = this.sessions.get(sessionId);
|
|
197
|
+
const listener = entry?.listener;
|
|
198
|
+
if (listener) listener(event);
|
|
199
|
+
else eventBuffer.push(event);
|
|
200
|
+
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
const timeout = setTimeout(() => {
|
|
203
|
+
resolve({ answer: '', wasFreeform: true });
|
|
204
|
+
}, 120000);
|
|
205
|
+
|
|
206
|
+
const storeResolve = (answer) => {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
resolve(answer);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (entry) {
|
|
212
|
+
entry.pendingInputResolve = storeResolve;
|
|
213
|
+
} else {
|
|
214
|
+
// Store on eventBuffer so it can be transferred after session registration
|
|
215
|
+
eventBuffer._pendingInputResolve = storeResolve;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (this.mcpServers) {
|
|
222
|
+
resumeConfig.mcpServers = this.mcpServers;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const session = await client.resumeSession(sdkSessionId, resumeConfig);
|
|
226
|
+
|
|
227
|
+
const unsubscribe = session.on((event) => {
|
|
228
|
+
try {
|
|
229
|
+
const mapped = this._mapEvent(event);
|
|
230
|
+
if (mapped) {
|
|
231
|
+
const entry = this.sessions.get(sessionId);
|
|
232
|
+
if (entry) {
|
|
233
|
+
if (!entry.messageHistory) entry.messageHistory = [];
|
|
234
|
+
entry.messageHistory.push(mapped);
|
|
235
|
+
}
|
|
236
|
+
const cb = entry?.listener;
|
|
237
|
+
if (cb) cb(mapped);
|
|
238
|
+
else eventBuffer.push(mapped);
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
log.warn(`Error processing event for session ${sessionId}: ${err.message}`);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Get existing messages for replay
|
|
246
|
+
const existingMessages = await session.getMessages().catch(() => []);
|
|
247
|
+
|
|
248
|
+
const now = new Date().toISOString();
|
|
249
|
+
this.sessions.set(sessionId, {
|
|
250
|
+
session,
|
|
251
|
+
unsubscribe,
|
|
252
|
+
eventBuffer,
|
|
253
|
+
listener: null,
|
|
254
|
+
pendingInputResolve: null,
|
|
255
|
+
cwd: options.cwd || process.cwd(),
|
|
256
|
+
model: options.model || 'claude-opus-4.6',
|
|
257
|
+
name: options.name || 'Resumed Session',
|
|
258
|
+
ptySessionId: options.ptySessionId || null,
|
|
259
|
+
createdAt: now,
|
|
260
|
+
lastActivity: now,
|
|
261
|
+
sdkSessionId,
|
|
262
|
+
existingMessages,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Transfer any pending input resolve stored before session registration
|
|
266
|
+
if (eventBuffer._pendingInputResolve) {
|
|
267
|
+
this.sessions.get(sessionId).pendingInputResolve = eventBuffer._pendingInputResolve;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
log.info(`Copilot SDK session resumed: ${sessionId} (from SDK session ${sdkSessionId})`);
|
|
271
|
+
return sessionId;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async createSession(options = {}) {
|
|
275
|
+
const client = await this.ensureClient();
|
|
276
|
+
const sessionId = require('crypto').randomBytes(16).toString('hex');
|
|
277
|
+
|
|
278
|
+
const eventBuffer = []; // Buffer events until a listener connects
|
|
279
|
+
let listener = null; // WebSocket event callback
|
|
280
|
+
|
|
281
|
+
const sessionCwd = options.cwd || process.cwd();
|
|
282
|
+
const configDir = path.join(os.homedir(), '.copilot');
|
|
283
|
+
|
|
284
|
+
const sessionConfig = {
|
|
285
|
+
model: options.model || 'claude-opus-4.6',
|
|
286
|
+
streaming: true,
|
|
287
|
+
workingDirectory: sessionCwd,
|
|
288
|
+
configDir,
|
|
289
|
+
onPermissionRequest: this._createPermissionHandler(sessionId, eventBuffer),
|
|
290
|
+
onUserInputRequest: async (request) => {
|
|
291
|
+
// Forward to WebSocket listener for UI to handle
|
|
292
|
+
const event = {
|
|
293
|
+
type: 'copilot.user_input_request',
|
|
294
|
+
data: {
|
|
295
|
+
question: request.question,
|
|
296
|
+
choices: request.choices,
|
|
297
|
+
allowFreeform: request.allowFreeform !== false,
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
const entry = this.sessions.get(sessionId);
|
|
301
|
+
listener = entry?.listener;
|
|
302
|
+
if (listener) listener(event);
|
|
303
|
+
else eventBuffer.push(event);
|
|
304
|
+
|
|
305
|
+
// Wait for response from UI (with timeout)
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
const timeout = setTimeout(() => {
|
|
308
|
+
resolve({ answer: '', wasFreeform: true });
|
|
309
|
+
}, 120000); // 2 min timeout
|
|
310
|
+
|
|
311
|
+
const storeResolve = (answer) => {
|
|
312
|
+
clearTimeout(timeout);
|
|
313
|
+
resolve(answer);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Store resolver for the WebSocket handler to call
|
|
317
|
+
if (entry) {
|
|
318
|
+
entry.pendingInputResolve = storeResolve;
|
|
319
|
+
} else {
|
|
320
|
+
// Store on eventBuffer so it can be transferred after session registration
|
|
321
|
+
eventBuffer._pendingInputResolve = storeResolve;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Pass user's MCP servers so all tools are available
|
|
328
|
+
if (this.mcpServers) {
|
|
329
|
+
sessionConfig.mcpServers = this.mcpServers;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const session = await client.createSession(sessionConfig);
|
|
333
|
+
|
|
334
|
+
// Set up event forwarding
|
|
335
|
+
const unsubscribe = session.on((event) => {
|
|
336
|
+
try {
|
|
337
|
+
const mapped = this._mapEvent(event);
|
|
338
|
+
if (mapped) {
|
|
339
|
+
const entry = this.sessions.get(sessionId);
|
|
340
|
+
// Store for replay on reconnect
|
|
341
|
+
if (entry) {
|
|
342
|
+
if (!entry.messageHistory) entry.messageHistory = [];
|
|
343
|
+
entry.messageHistory.push(mapped);
|
|
344
|
+
}
|
|
345
|
+
const cb = entry?.listener;
|
|
346
|
+
if (cb) cb(mapped);
|
|
347
|
+
else eventBuffer.push(mapped);
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
log.warn(`Error processing event for session ${sessionId}: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const now = new Date().toISOString();
|
|
355
|
+
this.sessions.set(sessionId, {
|
|
356
|
+
session,
|
|
357
|
+
unsubscribe,
|
|
358
|
+
eventBuffer,
|
|
359
|
+
listener: null,
|
|
360
|
+
pendingInputResolve: null,
|
|
361
|
+
cwd: options.cwd || process.cwd(),
|
|
362
|
+
model: options.model || 'claude-opus-4.6',
|
|
363
|
+
name: options.name || 'Copilot Session',
|
|
364
|
+
ptySessionId: options.ptySessionId || null,
|
|
365
|
+
createdAt: now,
|
|
366
|
+
lastActivity: now,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Transfer any pending input resolve stored before session registration
|
|
370
|
+
if (eventBuffer._pendingInputResolve) {
|
|
371
|
+
this.sessions.get(sessionId).pendingInputResolve = eventBuffer._pendingInputResolve;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
log.info(`Copilot SDK session created: ${sessionId}`);
|
|
375
|
+
return sessionId;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
setListener(sessionId, callback, owner) {
|
|
379
|
+
const entry = this.sessions.get(sessionId);
|
|
380
|
+
if (!entry) return false;
|
|
381
|
+
if (entry.listener && callback && entry.listener !== callback) {
|
|
382
|
+
log.warn(
|
|
383
|
+
`Overwriting existing listener for session ${sessionId} — only one client supported per copilot session`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
entry.listener = callback;
|
|
387
|
+
entry._listenerOwner = owner || null;
|
|
388
|
+
// Flush buffered events (only when attaching a real listener)
|
|
389
|
+
if (callback) {
|
|
390
|
+
while (entry.eventBuffer.length > 0) {
|
|
391
|
+
callback(entry.eventBuffer.shift());
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async sendMessage(sessionId, prompt) {
|
|
398
|
+
const entry = this.sessions.get(sessionId);
|
|
399
|
+
if (!entry) throw new Error('Session not found');
|
|
400
|
+
entry.lastActivity = new Date().toISOString();
|
|
401
|
+
// Store user message in history for replay on reconnect
|
|
402
|
+
if (!entry.messageHistory) entry.messageHistory = [];
|
|
403
|
+
entry.messageHistory.push({
|
|
404
|
+
type: 'copilot.user_message',
|
|
405
|
+
data: { content: prompt },
|
|
406
|
+
});
|
|
407
|
+
await entry.session.send({ prompt });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
respondToInput(sessionId, answer) {
|
|
411
|
+
const entry = this.sessions.get(sessionId);
|
|
412
|
+
if (!entry?.pendingInputResolve) return false;
|
|
413
|
+
entry.pendingInputResolve({
|
|
414
|
+
answer: answer.text,
|
|
415
|
+
wasFreeform: answer.wasFreeform !== false,
|
|
416
|
+
});
|
|
417
|
+
entry.pendingInputResolve = null;
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
getMessages(sessionId) {
|
|
422
|
+
const entry = this.sessions.get(sessionId);
|
|
423
|
+
if (!entry) return [];
|
|
424
|
+
|
|
425
|
+
// If we already have messageHistory, return it (it includes replayed existing messages)
|
|
426
|
+
if (entry.messageHistory && entry.messageHistory.length > 0) {
|
|
427
|
+
return entry.messageHistory;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// For resumed sessions with no messageHistory yet, convert existing SDK events
|
|
431
|
+
if (entry.existingMessages?.length) {
|
|
432
|
+
const existing = [];
|
|
433
|
+
for (const evt of entry.existingMessages) {
|
|
434
|
+
const mapped = this._mapEvent(evt);
|
|
435
|
+
if (mapped) existing.push(mapped);
|
|
436
|
+
if (evt.type === 'user.message' && evt.data?.content) {
|
|
437
|
+
existing.push({ type: 'copilot.user_message', data: { content: evt.data.content } });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return existing;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async setModel(sessionId, model) {
|
|
447
|
+
const entry = this.sessions.get(sessionId);
|
|
448
|
+
if (!entry) throw new Error('Session not found');
|
|
449
|
+
await entry.session.setModel(model);
|
|
450
|
+
entry.model = model;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async abortSession(sessionId) {
|
|
454
|
+
const entry = this.sessions.get(sessionId);
|
|
455
|
+
if (!entry) return;
|
|
456
|
+
try {
|
|
457
|
+
await entry.session.abort();
|
|
458
|
+
} catch {
|
|
459
|
+
// Ignore — session may already be idle
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async disconnectSession(sessionId) {
|
|
464
|
+
const entry = this.sessions.get(sessionId);
|
|
465
|
+
if (!entry) return;
|
|
466
|
+
// Clean up listener first to prevent leaks
|
|
467
|
+
if (entry.unsubscribe) {
|
|
468
|
+
try {
|
|
469
|
+
entry.unsubscribe();
|
|
470
|
+
} catch {
|
|
471
|
+
/* ignore */
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Clear any pending input timeout
|
|
475
|
+
if (entry.pendingInputResolve) {
|
|
476
|
+
entry.pendingInputResolve({ answer: '', wasFreeform: true });
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
await entry.session.disconnect();
|
|
480
|
+
} catch (err) {
|
|
481
|
+
log.warn(`Error disconnecting session ${sessionId}: ${err.message}`);
|
|
482
|
+
}
|
|
483
|
+
this.sessions.delete(sessionId);
|
|
484
|
+
log.info(`Copilot SDK session disconnected: ${sessionId}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
listSessions() {
|
|
488
|
+
return Array.from(this.sessions.keys());
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async listSdkSessions() {
|
|
492
|
+
try {
|
|
493
|
+
const client = await this.ensureClient();
|
|
494
|
+
const sessions = await client.listSessions();
|
|
495
|
+
return sessions.map((s) => ({
|
|
496
|
+
sessionId: s.sessionId,
|
|
497
|
+
startTime: s.startTime,
|
|
498
|
+
modifiedTime: s.modifiedTime,
|
|
499
|
+
summary: s.summary,
|
|
500
|
+
isRemote: s.isRemote,
|
|
501
|
+
cwd: s.context?.cwd,
|
|
502
|
+
repository: s.context?.repository,
|
|
503
|
+
branch: s.context?.branch,
|
|
504
|
+
}));
|
|
505
|
+
} catch {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
listSessionsDetailed() {
|
|
511
|
+
const result = [];
|
|
512
|
+
for (const [id, entry] of this.sessions) {
|
|
513
|
+
result.push({
|
|
514
|
+
id,
|
|
515
|
+
name: entry.name || 'Copilot Session',
|
|
516
|
+
cwd: entry.cwd || process.cwd(),
|
|
517
|
+
model: entry.model || 'claude-opus-4.6',
|
|
518
|
+
ptySessionId: entry.ptySessionId || null,
|
|
519
|
+
createdAt: entry.createdAt || new Date().toISOString(),
|
|
520
|
+
lastActivity: entry.lastActivity || entry.createdAt || new Date().toISOString(),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async shutdown() {
|
|
527
|
+
if (this._authPollTimer) clearInterval(this._authPollTimer);
|
|
528
|
+
for (const [id] of this.sessions) {
|
|
529
|
+
await this.disconnectSession(id);
|
|
530
|
+
}
|
|
531
|
+
if (this.client) {
|
|
532
|
+
await this.client.stop();
|
|
533
|
+
this.client = null;
|
|
534
|
+
}
|
|
535
|
+
log.info('Copilot SDK service shut down');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
_mapEvent(event) {
|
|
539
|
+
// Map SDK events to our WebSocket protocol
|
|
540
|
+
switch (event.type) {
|
|
541
|
+
case 'assistant.message':
|
|
542
|
+
return {
|
|
543
|
+
type: 'copilot.assistant_message',
|
|
544
|
+
data: {
|
|
545
|
+
content: event.data.content,
|
|
546
|
+
toolRequests: event.data.toolRequests,
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
case 'assistant.message_delta':
|
|
550
|
+
return {
|
|
551
|
+
type: 'copilot.message_delta',
|
|
552
|
+
data: { deltaContent: event.data.deltaContent },
|
|
553
|
+
};
|
|
554
|
+
case 'assistant.reasoning':
|
|
555
|
+
return {
|
|
556
|
+
type: 'copilot.reasoning',
|
|
557
|
+
data: { content: event.data.content },
|
|
558
|
+
};
|
|
559
|
+
case 'assistant.reasoning_delta':
|
|
560
|
+
return {
|
|
561
|
+
type: 'copilot.reasoning_delta',
|
|
562
|
+
data: { deltaContent: event.data.deltaContent },
|
|
563
|
+
};
|
|
564
|
+
case 'tool.execution_start':
|
|
565
|
+
return {
|
|
566
|
+
type: 'copilot.tool_start',
|
|
567
|
+
data: {
|
|
568
|
+
toolCallId: event.data?.toolCallId,
|
|
569
|
+
toolName: event.data?.toolName,
|
|
570
|
+
input: event.data?.arguments || event.data?.input || event.data?.args || {},
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
case 'tool.execution_complete':
|
|
574
|
+
return {
|
|
575
|
+
type: 'copilot.tool_complete',
|
|
576
|
+
data: {
|
|
577
|
+
toolCallId: event.data?.toolCallId,
|
|
578
|
+
toolName: event.data?.toolName,
|
|
579
|
+
result: event.data?.result || event.data?.output || event.data,
|
|
580
|
+
duration: event.data?.duration || event.data?.durationMs,
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
case 'subagent.started':
|
|
584
|
+
return {
|
|
585
|
+
type: 'copilot.subagent_start',
|
|
586
|
+
data: {
|
|
587
|
+
toolCallId: event.data?.toolCallId,
|
|
588
|
+
agentName: event.data?.agentName,
|
|
589
|
+
agentDisplayName: event.data?.agentDisplayName,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
case 'subagent.completed':
|
|
593
|
+
return {
|
|
594
|
+
type: 'copilot.subagent_complete',
|
|
595
|
+
data: {
|
|
596
|
+
toolCallId: event.data?.toolCallId,
|
|
597
|
+
agentDisplayName: event.data?.agentDisplayName,
|
|
598
|
+
model: event.data?.model,
|
|
599
|
+
totalToolCalls: event.data?.totalToolCalls,
|
|
600
|
+
totalTokens: event.data?.totalTokens,
|
|
601
|
+
durationMs: event.data?.durationMs,
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
case 'session.idle':
|
|
605
|
+
return { type: 'copilot.idle', data: {} };
|
|
606
|
+
case 'session.mode_changed':
|
|
607
|
+
return {
|
|
608
|
+
type: 'copilot.mode_changed',
|
|
609
|
+
data: { mode: event.data?.mode },
|
|
610
|
+
};
|
|
611
|
+
default:
|
|
612
|
+
return null; // Skip internal events
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
module.exports = { CopilotService };
|
package/src/server/index.js
CHANGED
|
@@ -105,10 +105,18 @@ function createTermBeamServer(overrides = {}) {
|
|
|
105
105
|
const server = http.createServer(app);
|
|
106
106
|
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
|
|
107
107
|
|
|
108
|
+
let copilotService = null;
|
|
109
|
+
try {
|
|
110
|
+
const { CopilotService } = require('./copilot-sdk');
|
|
111
|
+
if (CopilotService) copilotService = new CopilotService();
|
|
112
|
+
} catch {
|
|
113
|
+
log.info('Copilot SDK not available — agent features disabled');
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
const state = { shareBaseUrl: null, updateInfo: null, wss, tunnelStatus: null, getLoginInfo };
|
|
109
117
|
app.use('/preview', auth.middleware, createPreviewProxy());
|
|
110
|
-
setupRoutes(app, { auth, sessions, config, state, pushManager });
|
|
111
|
-
setupWebSocket(wss, { auth, sessions });
|
|
118
|
+
setupRoutes(app, { auth, sessions, config, state, pushManager, copilotService });
|
|
119
|
+
setupWebSocket(wss, { auth, sessions, copilotService });
|
|
112
120
|
|
|
113
121
|
// --- Lifecycle ---
|
|
114
122
|
let shuttingDown = false;
|
|
@@ -118,6 +126,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
118
126
|
log.info('Shutdown initiated');
|
|
119
127
|
auth.cleanup();
|
|
120
128
|
sessions.shutdown();
|
|
129
|
+
if (copilotService) copilotService.shutdown().catch(() => {});
|
|
121
130
|
cleanupUploadedFiles();
|
|
122
131
|
tunnelEvents.removeAllListeners();
|
|
123
132
|
cleanupTunnel();
|