@devmarketplacenpm/devmp 0.1.1-beta.5

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.
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+
3
+ const { createExecutor } = require("./executor");
4
+ const { createCommandRunner } = require("./command-runner");
5
+ const { agentWsUrl: wsUrl } = require("./routes");
6
+ const {
7
+ record: recordClientVersion,
8
+ blockedMessage: clientBlockedMessage,
9
+ } = require("./version");
10
+ const { readProjectInstructions } = require("./instructions");
11
+
12
+ // A /new or /compact that the server never answers must not hold the shell
13
+ // hostage. Generous, because compaction runs a summarisation model server-side.
14
+ const CONTROL_TIMEOUT_MS =
15
+ Number(process.env.DEVMP_CONTROL_TIMEOUT_MS) > 0
16
+ ? Number(process.env.DEVMP_CONTROL_TIMEOUT_MS)
17
+ : 60_000;
18
+
19
+ /** Open one M4 socket that can carry many sequential turns. */
20
+ async function connectInteractiveSession({
21
+ session,
22
+ rootDir,
23
+ sessionId,
24
+ history,
25
+ yes,
26
+ allowCommands,
27
+ checkpoint,
28
+ onSession,
29
+ onNotice,
30
+ }) {
31
+ // Session-scoped: read once here, and the server carries them into every turn
32
+ // of this session rather than the client resending them each time.
33
+ const instructions = await readProjectInstructions(rootDir).catch(() => null);
34
+ if (instructions) {
35
+ onNotice?.(
36
+ ` Following ${instructions.source}${
37
+ instructions.truncated ? " (first 8 KB)" : ""
38
+ }`,
39
+ );
40
+ }
41
+
42
+ return new Promise((resolve, reject) => {
43
+ if (typeof WebSocket !== "function") {
44
+ reject(
45
+ new Error(
46
+ "Interactive sessions require Node.js 22 or newer (global WebSocket is unavailable)."
47
+ )
48
+ );
49
+ return;
50
+ }
51
+
52
+ const url = wsUrl(session.apiBaseUrl);
53
+ let ws;
54
+ try {
55
+ ws = new WebSocket(url);
56
+ } catch (error) {
57
+ reject(new Error(`Could not open ${url}: ${error.message}`));
58
+ return;
59
+ }
60
+
61
+ let ready = false;
62
+ let closed = false;
63
+ let currentSession = null;
64
+ let pendingTurn = null;
65
+ // /new and /compact are request/response over the same socket. Every path
66
+ // that can end the exchange has to settle this — a dropped socket, a server
67
+ // error frame, a server that never answers. An unsettled control request
68
+ // wedges the shell: the composer stays busy with no way back.
69
+ let pendingControl = null;
70
+
71
+ const executor = createExecutor({
72
+ rootDir,
73
+ yes,
74
+ reviewChanges: true,
75
+ checkpoint,
76
+ onFile: (path, outcome, meta = {}) =>
77
+ pendingTurn?.onEvent({ type: "file-local", path, outcome, ...meta }),
78
+ });
79
+ const commandRunner = createCommandRunner({
80
+ rootDir,
81
+ allowCommands,
82
+ onNotice,
83
+ });
84
+
85
+ const send = (message) => {
86
+ if (ws.readyState !== WebSocket.OPEN) return false;
87
+ ws.send(JSON.stringify(message));
88
+ return true;
89
+ };
90
+
91
+ const settleControl = (outcome) => {
92
+ const control = pendingControl;
93
+ if (!control) return false;
94
+ pendingControl = null;
95
+ clearTimeout(control.timer);
96
+ if (outcome.error) control.reject(outcome.error);
97
+ else control.resolve(outcome.value);
98
+ return true;
99
+ };
100
+
101
+ const awaitControl = (label, frame) => {
102
+ if (pendingTurn) {
103
+ return Promise.reject(new Error("Cancel the active turn first."));
104
+ }
105
+ if (pendingControl) {
106
+ return Promise.reject(
107
+ new Error("/" + pendingControl.label + " is already running.")
108
+ );
109
+ }
110
+ return new Promise((resolve, reject) => {
111
+ if (!send(frame)) {
112
+ reject(new Error("The agent tunnel is not connected."));
113
+ return;
114
+ }
115
+ const timer = setTimeout(() => {
116
+ settleControl({
117
+ error: new Error(
118
+ "The server did not answer /" +
119
+ label +
120
+ " within " +
121
+ CONTROL_TIMEOUT_MS / 1000 +
122
+ "s. Nothing was changed."
123
+ ),
124
+ });
125
+ }, CONTROL_TIMEOUT_MS);
126
+ timer.unref?.();
127
+ pendingControl = { label, resolve, reject, timer };
128
+ });
129
+ };
130
+
131
+ const api = {
132
+ get session() {
133
+ return currentSession;
134
+ },
135
+ async runTurn({
136
+ turnId,
137
+ prompt,
138
+ mode,
139
+ provider,
140
+ model,
141
+ maxFiles,
142
+ onEvent,
143
+ }) {
144
+ if (pendingTurn) {
145
+ throw new Error("A turn is already running.");
146
+ }
147
+ await checkpoint?.beginTurn({ turnId, prompt });
148
+ commandRunner.beginTurn();
149
+ return new Promise((turnResolve) => {
150
+ pendingTurn = { turnId, resolve: turnResolve, onEvent };
151
+ send({
152
+ type: "turn.start",
153
+ turnId,
154
+ prompt,
155
+ mode,
156
+ ...(provider ? { provider } : {}),
157
+ ...(model ? { model } : {}),
158
+ ...(maxFiles ? { maxFiles } : {}),
159
+ });
160
+ });
161
+ },
162
+ cancel(turnId) {
163
+ send({ type: "turn.cancel", ...(turnId ? { turnId } : {}) });
164
+ },
165
+ reset() {
166
+ return awaitControl("new", { type: "session.reset" });
167
+ },
168
+ /**
169
+ * Fold the older half of the conversation into a summary now. Refused
170
+ * mid-turn: it rewrites the history the running turn reasons from.
171
+ */
172
+ compact() {
173
+ return awaitControl("compact", { type: "session.compact" });
174
+ },
175
+ setPermissions({ overwriteAll, commandsAll }) {
176
+ executor.setOverwriteAll(overwriteAll);
177
+ commandRunner.setAllowCommands(commandsAll);
178
+ },
179
+ /** Background jobs for the shell's `/jobs` view — no socket round-trip. */
180
+ listJobs: () => commandRunner.listJobs(),
181
+ stopJob: (jobId) => commandRunner.stopJob(jobId),
182
+ async close() {
183
+ closed = true;
184
+ commandRunner.cancelAll();
185
+ settleControl({ error: new Error("Session closed.") });
186
+ send({ type: "session.close" });
187
+ // Background jobs are session-scoped: this is the point at which a
188
+ // still-running dev server must not be left behind. Awaited, because
189
+ // the escalation to SIGKILL has to land before the process exits.
190
+ await commandRunner.killAllJobs();
191
+ ws.close();
192
+ },
193
+ };
194
+
195
+ const settleTurn = async (message) => {
196
+ if (!pendingTurn) return;
197
+ const turn = pendingTurn;
198
+ pendingTurn = null;
199
+ const status =
200
+ message.type === "done"
201
+ ? "completed"
202
+ : message.type === "cancelled"
203
+ ? "cancelled"
204
+ : "failed";
205
+ let savedCheckpoint = null;
206
+ let checkpointError;
207
+ try {
208
+ savedCheckpoint = await checkpoint?.finishTurn(status);
209
+ } catch (error) {
210
+ checkpointError = error.message;
211
+ }
212
+ const terminal = {
213
+ ...message,
214
+ localChanges: savedCheckpoint?.changes || [],
215
+ checkpoint: savedCheckpoint
216
+ ? { id: savedCheckpoint.id, status: savedCheckpoint.status }
217
+ : null,
218
+ executedCommands: commandRunner.getTurnReport(),
219
+ ...(checkpointError ? { checkpointError } : {}),
220
+ };
221
+ turn.onEvent(terminal);
222
+ turn.resolve(terminal);
223
+ };
224
+
225
+ ws.onopen = () => {
226
+ send({
227
+ type: "session.start",
228
+ token: session.accessToken,
229
+ ...(sessionId ? { sessionId } : {}),
230
+ workspace: rootDir,
231
+ history: Array.isArray(history) ? history.slice(-40) : [],
232
+ ...(instructions
233
+ ? {
234
+ instructions: instructions.text,
235
+ instructionsSource: instructions.source,
236
+ }
237
+ : {}),
238
+ });
239
+ };
240
+
241
+ ws.onmessage = async (event) => {
242
+ let msg;
243
+ try {
244
+ const raw =
245
+ typeof event.data === "string" ? event.data : event.data.toString();
246
+ msg = JSON.parse(raw);
247
+ } catch {
248
+ return;
249
+ }
250
+
251
+ if (msg.type === "fs") {
252
+ try {
253
+ const payload = await executor.handle(msg.op, msg.args || {});
254
+ send({ type: "fsResult", id: msg.id, ok: true, ...payload });
255
+ } catch (error) {
256
+ send({
257
+ type: "fsResult",
258
+ id: msg.id,
259
+ ok: false,
260
+ message: error.message,
261
+ });
262
+ }
263
+ return;
264
+ }
265
+
266
+ // Foreground and background command frames all answer with `cmdResult`,
267
+ // so the server-side runner keeps one pending map keyed by request id.
268
+ const commandOps = {
269
+ cmd: () => commandRunner.handle(String(msg.command || ""), msg.id),
270
+ cmdStart: () => commandRunner.handleStart(String(msg.command || "")),
271
+ cmdCheck: () => commandRunner.handleCheck(String(msg.jobId || "")),
272
+ cmdStop: () => commandRunner.handleStop(String(msg.jobId || "")),
273
+ cmdList: () => commandRunner.handleList(),
274
+ };
275
+ if (commandOps[msg.type]) {
276
+ try {
277
+ const payload = await commandOps[msg.type]();
278
+ send({ type: "cmdResult", id: msg.id, ok: true, ...payload });
279
+ } catch (error) {
280
+ send({
281
+ type: "cmdResult",
282
+ id: msg.id,
283
+ ok: false,
284
+ message: error.message,
285
+ });
286
+ }
287
+ return;
288
+ }
289
+
290
+ if (msg.type === "cmdCancel") {
291
+ commandRunner.cancel(msg.id);
292
+ return;
293
+ }
294
+
295
+ if (msg.type === "session.ready") {
296
+ // The handshake is the last cheap moment to refuse: a client older
297
+ // than the server's minimum would otherwise fail somewhere inside a
298
+ // turn, where the message means nothing to the user.
299
+ recordClientVersion(msg.client);
300
+ const tooOld = clientBlockedMessage();
301
+ if (tooOld) {
302
+ try {
303
+ ws.close();
304
+ } catch {
305
+ /* already closing */
306
+ }
307
+ reject(new Error(tooOld));
308
+ return;
309
+ }
310
+ ready = true;
311
+ currentSession = {
312
+ sessionId: msg.sessionId,
313
+ resumed: Boolean(msg.resumed),
314
+ history: Array.isArray(msg.history) ? msg.history : [],
315
+ };
316
+ onSession?.(currentSession);
317
+ resolve(api);
318
+ return;
319
+ }
320
+
321
+ if (msg.type === "session.updated") {
322
+ currentSession = {
323
+ ...(currentSession || {}),
324
+ sessionId: msg.sessionId,
325
+ resumed: true,
326
+ history: Array.isArray(msg.history) ? msg.history : [],
327
+ };
328
+ onSession?.(currentSession);
329
+ return;
330
+ }
331
+
332
+ if (msg.type === "session.compacted") {
333
+ // The server rewrote history; adopt it wholesale so the local
334
+ // transcript cannot re-seed the pre-compaction version on resume.
335
+ currentSession = {
336
+ ...(currentSession || {}),
337
+ sessionId: msg.sessionId,
338
+ resumed: true,
339
+ history: Array.isArray(msg.history) ? msg.history : [],
340
+ };
341
+ onSession?.(currentSession);
342
+ settleControl({
343
+ value: {
344
+ compacted: Boolean(msg.compacted),
345
+ messages: currentSession.history.length,
346
+ },
347
+ });
348
+ return;
349
+ }
350
+
351
+ if (msg.type === "session.reset.done") {
352
+ currentSession = {
353
+ ...(currentSession || {}),
354
+ sessionId: msg.sessionId,
355
+ resumed: false,
356
+ history: [],
357
+ };
358
+ onSession?.(currentSession);
359
+ settleControl({ value: currentSession });
360
+ return;
361
+ }
362
+
363
+ if (msg.turnId && pendingTurn && msg.turnId !== pendingTurn.turnId) {
364
+ return;
365
+ }
366
+
367
+ if (
368
+ pendingTurn &&
369
+ (msg.type === "done" ||
370
+ msg.type === "error" ||
371
+ msg.type === "cancelled")
372
+ ) {
373
+ await settleTurn(msg);
374
+ } else if (pendingTurn) {
375
+ pendingTurn.onEvent(msg);
376
+ } else if (!ready && msg.type === "error") {
377
+ reject(new Error(msg.message || "Could not start the CLI session."));
378
+ ws.close();
379
+ } else if (msg.type === "error") {
380
+ // No turn is running, so a bare error frame is the server refusing the
381
+ // control request we are waiting on. Without this it is swallowed and
382
+ // /compact never returns.
383
+ settleControl({
384
+ error: new Error(msg.message || "The server rejected that command."),
385
+ });
386
+ }
387
+ };
388
+
389
+ ws.onerror = (event) => {
390
+ const message =
391
+ event?.message || event?.error?.message || "connection failed";
392
+ if (!ready) reject(new Error(`Could not reach ${url} (${message}).`));
393
+ if (pendingTurn) {
394
+ void settleTurn({ type: "error", message });
395
+ }
396
+ settleControl({ error: new Error(message) });
397
+ };
398
+
399
+ ws.onclose = () => {
400
+ commandRunner.cancelAll();
401
+ if (!ready && !closed)
402
+ reject(new Error(`The agent tunnel closed at ${url}.`));
403
+ if (pendingTurn) {
404
+ void settleTurn({
405
+ type: "error",
406
+ message: closed
407
+ ? "Session closed."
408
+ : "The agent tunnel disconnected.",
409
+ });
410
+ }
411
+ settleControl({
412
+ error: new Error(
413
+ closed ? "Session closed." : "The agent tunnel disconnected."
414
+ ),
415
+ });
416
+ };
417
+ });
418
+ }
419
+
420
+ module.exports = { connectInteractiveSession };