@akira-tl/forgerelay 0.2.4 → 0.2.6
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 +17 -0
- package/dist/artifact-tools.js +2 -3
- package/dist/logger.js +77 -10
- package/dist/mcp/server-instructions.js +4 -4
- package/dist/mcp-app-template.js +45 -0
- package/dist/mcp-sessions.js +24 -22
- package/dist/process-sessions.js +135 -108
- package/dist/roots.js +1 -1
- package/dist/server.js +165 -88
- package/docs/chatgpt-coding-workflow.md +3 -2
- package/docs/configuration.md +11 -6
- package/docs/debugging.md +35 -7
- package/docs/gotchas.md +22 -1
- package/docs/roadmap.md +26 -0
- package/docs/security.md +3 -2
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +79 -3
- package/scripts/debug/runtime.mjs +2 -1
package/dist/process-sessions.js
CHANGED
|
@@ -8,7 +8,7 @@ const MAX_COMMAND_YIELD_MS = 300_000;
|
|
|
8
8
|
const MAX_POLL_YIELD_MS = 300_000;
|
|
9
9
|
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
10
10
|
const DEFAULT_BUFFER_CHARACTERS = 1_000_000;
|
|
11
|
-
const
|
|
11
|
+
const COMPLETED_PROCESS_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
12
12
|
const DEFAULT_COLUMNS = 80;
|
|
13
13
|
const DEFAULT_ROWS = 24;
|
|
14
14
|
function boundedInteger(value, fallback, maximum) {
|
|
@@ -27,6 +27,19 @@ function terminalSize(value, fallback) {
|
|
|
27
27
|
}
|
|
28
28
|
return value;
|
|
29
29
|
}
|
|
30
|
+
export function resolveProcessId(processId, legacySessionId) {
|
|
31
|
+
if (processId !== undefined && legacySessionId !== undefined && processId !== legacySessionId) {
|
|
32
|
+
throw new Error("processId and deprecated sessionId must identify the same process when both are provided.");
|
|
33
|
+
}
|
|
34
|
+
const resolved = processId ?? legacySessionId;
|
|
35
|
+
if (resolved === undefined) {
|
|
36
|
+
throw new Error("A processId is required. Deprecated sessionId remains accepted for compatibility.");
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isInteger(resolved) || resolved < 1) {
|
|
39
|
+
throw new Error("processId must be a positive integer.");
|
|
40
|
+
}
|
|
41
|
+
return resolved;
|
|
42
|
+
}
|
|
30
43
|
function processEnvironment(input) {
|
|
31
44
|
return {
|
|
32
45
|
...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined && (input?.codexCi || entry[0] !== "CODEX_CI"))),
|
|
@@ -129,110 +142,120 @@ function truncateOutput(output, maxCharacters) {
|
|
|
129
142
|
truncated: true,
|
|
130
143
|
};
|
|
131
144
|
}
|
|
132
|
-
export class
|
|
133
|
-
|
|
145
|
+
export class ProcessManager {
|
|
146
|
+
processes = new Map();
|
|
134
147
|
completedByWorkspace = new Map();
|
|
135
148
|
maxBufferCharacters;
|
|
136
|
-
|
|
149
|
+
completedProcessTtlMs;
|
|
137
150
|
maxStartYieldMs;
|
|
138
|
-
|
|
151
|
+
monotonicNow;
|
|
152
|
+
nextProcessId = 1;
|
|
139
153
|
constructor(options = {}) {
|
|
140
154
|
this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
|
|
141
|
-
this.
|
|
155
|
+
this.completedProcessTtlMs = options.completedProcessTtlMs
|
|
156
|
+
?? options.completedSessionTtlMs
|
|
157
|
+
?? COMPLETED_PROCESS_TTL_MS;
|
|
142
158
|
this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
|
|
159
|
+
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
143
160
|
}
|
|
144
161
|
async start(input) {
|
|
145
|
-
const
|
|
146
|
-
this.
|
|
162
|
+
const processEntry = this.createProcess(input);
|
|
163
|
+
this.processes.set(processEntry.id, processEntry);
|
|
147
164
|
try {
|
|
148
165
|
if (input.tty && process.platform !== "win32")
|
|
149
|
-
await this.startPty(
|
|
166
|
+
await this.startPty(processEntry, input);
|
|
150
167
|
else
|
|
151
|
-
this.startPipe(
|
|
168
|
+
this.startPipe(processEntry, input);
|
|
152
169
|
}
|
|
153
170
|
catch (error) {
|
|
154
|
-
this.
|
|
171
|
+
this.processes.delete(processEntry.id);
|
|
155
172
|
throw error;
|
|
156
173
|
}
|
|
157
174
|
const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
|
|
158
|
-
await this.waitForExit(
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
const snapshot = this.consume(
|
|
175
|
+
await this.waitForExit(processEntry, yieldTimeMs);
|
|
176
|
+
if (processEntry.running)
|
|
177
|
+
processEntry.background = true;
|
|
178
|
+
const snapshot = this.consume(processEntry, input.maxOutputTokens);
|
|
162
179
|
if (!snapshot.running)
|
|
163
|
-
this.
|
|
180
|
+
this.removeProcess(processEntry.id);
|
|
164
181
|
return snapshot;
|
|
165
182
|
}
|
|
166
183
|
async write(input) {
|
|
167
|
-
const
|
|
184
|
+
const processId = resolveProcessId(input.processId, input.sessionId);
|
|
185
|
+
const processEntry = this.getOwnedProcess(input.workspaceId, processId);
|
|
168
186
|
const chars = input.chars ?? "";
|
|
169
187
|
const interactionRequested = chars.length > 0 || input.columns !== undefined || input.rows !== undefined;
|
|
170
188
|
if (input.columns !== undefined || input.rows !== undefined) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!
|
|
174
|
-
throw new Error(`Process
|
|
189
|
+
processEntry.columns = terminalSize(input.columns, processEntry.columns);
|
|
190
|
+
processEntry.rows = terminalSize(input.rows, processEntry.rows);
|
|
191
|
+
if (!processEntry.process?.resize) {
|
|
192
|
+
throw new Error(`Process ${processEntry.id} is not a PTY and cannot be resized.`);
|
|
175
193
|
}
|
|
176
|
-
|
|
194
|
+
processEntry.process.resize(processEntry.columns, processEntry.rows);
|
|
177
195
|
}
|
|
178
|
-
const interruptRequested = chars.includes("\u0003") &&
|
|
196
|
+
const interruptRequested = chars.includes("\u0003") && processEntry.running;
|
|
179
197
|
if (interruptRequested) {
|
|
180
|
-
|
|
198
|
+
processEntry.process?.kill("SIGINT");
|
|
181
199
|
}
|
|
182
200
|
const writableChars = chars.replaceAll("\u0003", "");
|
|
183
|
-
if (writableChars &&
|
|
184
|
-
|
|
185
|
-
if ((interactionRequested || !
|
|
201
|
+
if (writableChars && processEntry.running)
|
|
202
|
+
processEntry.process?.write(writableChars);
|
|
203
|
+
if ((interactionRequested || !processEntry.buffer.hasOutput()) && processEntry.running) {
|
|
186
204
|
const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS;
|
|
187
205
|
const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS;
|
|
188
206
|
const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum);
|
|
189
|
-
await this.waitForExit(
|
|
207
|
+
await this.waitForExit(processEntry, yieldTimeMs);
|
|
190
208
|
}
|
|
191
|
-
const snapshot = this.consume(
|
|
192
|
-
if (!
|
|
193
|
-
this.
|
|
209
|
+
const snapshot = this.consume(processEntry, input.maxOutputTokens);
|
|
210
|
+
if (!processEntry.running)
|
|
211
|
+
this.removeProcess(processEntry.id);
|
|
194
212
|
return snapshot;
|
|
195
213
|
}
|
|
196
214
|
activeWorkspaceIds() {
|
|
197
|
-
return new Set([...this.
|
|
215
|
+
return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
|
|
198
216
|
}
|
|
199
|
-
takeCompleted(workspaceId, maxOutputTokens,
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
217
|
+
takeCompleted(workspaceId, maxOutputTokens, excludeProcessId) {
|
|
218
|
+
const processIds = this.completedByWorkspace.get(workspaceId) ?? [];
|
|
219
|
+
if (processIds.length === 0)
|
|
202
220
|
return [];
|
|
203
221
|
const completed = [];
|
|
204
|
-
for (const
|
|
205
|
-
if (
|
|
222
|
+
for (const processId of processIds) {
|
|
223
|
+
if (processId === excludeProcessId)
|
|
206
224
|
continue;
|
|
207
|
-
const
|
|
208
|
-
if (!
|
|
225
|
+
const processEntry = this.processes.get(processId);
|
|
226
|
+
if (!processEntry || processEntry.running)
|
|
209
227
|
continue;
|
|
210
|
-
const snapshot = this.consume(
|
|
211
|
-
completed.push({
|
|
212
|
-
|
|
228
|
+
const snapshot = this.consume(processEntry, maxOutputTokens);
|
|
229
|
+
completed.push({
|
|
230
|
+
...snapshot,
|
|
231
|
+
processId: processEntry.id,
|
|
232
|
+
sessionId: processEntry.id,
|
|
233
|
+
command: processEntry.command,
|
|
234
|
+
});
|
|
235
|
+
this.removeProcess(processEntry.id);
|
|
213
236
|
}
|
|
214
237
|
return completed;
|
|
215
238
|
}
|
|
216
|
-
terminate(workspaceId,
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
239
|
+
terminate(workspaceId, processId) {
|
|
240
|
+
const processEntry = this.getOwnedProcess(workspaceId, processId);
|
|
241
|
+
if (processEntry.running)
|
|
242
|
+
processEntry.process?.kill("SIGTERM");
|
|
220
243
|
}
|
|
221
244
|
shutdown() {
|
|
222
|
-
for (const
|
|
223
|
-
if (
|
|
224
|
-
clearTimeout(
|
|
225
|
-
if (
|
|
226
|
-
|
|
245
|
+
for (const processEntry of this.processes.values()) {
|
|
246
|
+
if (processEntry.cleanupTimer)
|
|
247
|
+
clearTimeout(processEntry.cleanupTimer);
|
|
248
|
+
if (processEntry.running)
|
|
249
|
+
processEntry.process?.kill("SIGTERM");
|
|
227
250
|
}
|
|
228
|
-
this.
|
|
251
|
+
this.processes.clear();
|
|
229
252
|
this.completedByWorkspace.clear();
|
|
230
253
|
}
|
|
231
|
-
async waitForExit(
|
|
254
|
+
async waitForExit(processEntry, yieldTimeMs) {
|
|
232
255
|
let timer;
|
|
233
256
|
try {
|
|
234
257
|
await Promise.race([
|
|
235
|
-
|
|
258
|
+
processEntry.exitPromise,
|
|
236
259
|
new Promise((resolve) => {
|
|
237
260
|
timer = setTimeout(resolve, yieldTimeMs);
|
|
238
261
|
}),
|
|
@@ -243,16 +266,16 @@ export class ProcessSessionManager {
|
|
|
243
266
|
clearTimeout(timer);
|
|
244
267
|
}
|
|
245
268
|
}
|
|
246
|
-
|
|
269
|
+
createProcess(input) {
|
|
247
270
|
let resolveExit = () => undefined;
|
|
248
271
|
const exitPromise = new Promise((resolve) => {
|
|
249
272
|
resolveExit = resolve;
|
|
250
273
|
});
|
|
251
274
|
return {
|
|
252
|
-
id: this.
|
|
275
|
+
id: this.nextProcessId++,
|
|
253
276
|
workspaceId: input.workspaceId,
|
|
254
277
|
command: input.command,
|
|
255
|
-
|
|
278
|
+
startedAtMonotonic: this.monotonicNow(),
|
|
256
279
|
columns: terminalSize(input.columns, DEFAULT_COLUMNS),
|
|
257
280
|
rows: terminalSize(input.rows, DEFAULT_ROWS),
|
|
258
281
|
buffer: new HeadTailBuffer(this.maxBufferCharacters),
|
|
@@ -262,7 +285,7 @@ export class ProcessSessionManager {
|
|
|
262
285
|
resolveExit,
|
|
263
286
|
};
|
|
264
287
|
}
|
|
265
|
-
startPipe(
|
|
288
|
+
startPipe(processEntry, input) {
|
|
266
289
|
const shell = resolveShellCommand(input.command);
|
|
267
290
|
const detached = process.platform !== "win32";
|
|
268
291
|
const child = spawn(input.command, {
|
|
@@ -277,17 +300,17 @@ export class ProcessSessionManager {
|
|
|
277
300
|
detached,
|
|
278
301
|
shell: shell.executable,
|
|
279
302
|
});
|
|
280
|
-
|
|
303
|
+
processEntry.process = {
|
|
281
304
|
write: (data) => child.stdin.write(data),
|
|
282
305
|
kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached),
|
|
283
306
|
resize: input.tty ? () => undefined : undefined,
|
|
284
307
|
};
|
|
285
|
-
child.stdout.on("data", (data) => this.append(
|
|
286
|
-
child.stderr.on("data", (data) => this.append(
|
|
287
|
-
child.on("error", (error) => this.append(
|
|
288
|
-
child.on("close", (code, signal) => this.finish(
|
|
308
|
+
child.stdout.on("data", (data) => this.append(processEntry, data.toString("utf8")));
|
|
309
|
+
child.stderr.on("data", (data) => this.append(processEntry, data.toString("utf8")));
|
|
310
|
+
child.on("error", (error) => this.append(processEntry, `${error.message}\n`));
|
|
311
|
+
child.on("close", (code, signal) => this.finish(processEntry, code ?? undefined, signal ?? undefined));
|
|
289
312
|
}
|
|
290
|
-
async startPty(
|
|
313
|
+
async startPty(processEntry, input) {
|
|
291
314
|
let nodePty;
|
|
292
315
|
try {
|
|
293
316
|
nodePty = await import("node-pty");
|
|
@@ -306,80 +329,84 @@ export class ProcessSessionManager {
|
|
|
306
329
|
codexCi: input.codexCi,
|
|
307
330
|
}),
|
|
308
331
|
name: "xterm-256color",
|
|
309
|
-
cols:
|
|
310
|
-
rows:
|
|
332
|
+
cols: processEntry.columns,
|
|
333
|
+
rows: processEntry.rows,
|
|
311
334
|
});
|
|
312
335
|
}
|
|
313
336
|
catch (error) {
|
|
314
337
|
throw error;
|
|
315
338
|
}
|
|
316
|
-
|
|
339
|
+
processEntry.process = {
|
|
317
340
|
write: (data) => pty.write(data),
|
|
318
341
|
kill: (signal) => pty.kill(signal),
|
|
319
342
|
resize: (columns, rows) => pty.resize(columns, rows),
|
|
320
343
|
};
|
|
321
|
-
pty.onData((data) => this.append(
|
|
344
|
+
pty.onData((data) => this.append(processEntry, data));
|
|
322
345
|
pty.onExit(({ exitCode, signal }) => {
|
|
323
|
-
this.finish(
|
|
346
|
+
this.finish(processEntry, exitCode, signal === 0 ? undefined : String(signal));
|
|
324
347
|
});
|
|
325
348
|
}
|
|
326
|
-
finish(
|
|
327
|
-
if (!
|
|
349
|
+
finish(processEntry, exitCode, signal) {
|
|
350
|
+
if (!processEntry.running)
|
|
328
351
|
return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
const completed = this.completedByWorkspace.get(
|
|
335
|
-
if (!completed.includes(
|
|
336
|
-
completed.push(
|
|
337
|
-
this.completedByWorkspace.set(
|
|
352
|
+
processEntry.running = false;
|
|
353
|
+
processEntry.exitCode = exitCode;
|
|
354
|
+
processEntry.signal = signal;
|
|
355
|
+
processEntry.resolveExit();
|
|
356
|
+
if (processEntry.background) {
|
|
357
|
+
const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
|
|
358
|
+
if (!completed.includes(processEntry.id)) {
|
|
359
|
+
completed.push(processEntry.id);
|
|
360
|
+
this.completedByWorkspace.set(processEntry.workspaceId, completed);
|
|
338
361
|
}
|
|
339
362
|
}
|
|
340
|
-
|
|
341
|
-
|
|
363
|
+
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
|
|
364
|
+
processEntry.cleanupTimer.unref();
|
|
342
365
|
}
|
|
343
|
-
append(
|
|
344
|
-
|
|
366
|
+
append(processEntry, output) {
|
|
367
|
+
processEntry.buffer.append(output);
|
|
345
368
|
}
|
|
346
|
-
consume(
|
|
369
|
+
consume(processEntry, maxOutputTokens) {
|
|
347
370
|
const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
|
|
348
371
|
const maxCharacters = Math.max(256, limit * 4);
|
|
349
|
-
const buffered =
|
|
372
|
+
const buffered = processEntry.buffer.drain(maxCharacters);
|
|
373
|
+
const processId = processEntry.running ? processEntry.id : undefined;
|
|
350
374
|
return {
|
|
351
|
-
|
|
375
|
+
processId,
|
|
376
|
+
sessionId: processId,
|
|
352
377
|
output: buffered.output,
|
|
353
378
|
outputTruncated: buffered.truncated,
|
|
354
|
-
running:
|
|
355
|
-
exitCode:
|
|
356
|
-
signal:
|
|
357
|
-
wallTimeMs:
|
|
379
|
+
running: processEntry.running,
|
|
380
|
+
exitCode: processEntry.exitCode,
|
|
381
|
+
signal: processEntry.signal,
|
|
382
|
+
wallTimeMs: Math.max(0, Math.round(this.monotonicNow() - processEntry.startedAtMonotonic)),
|
|
358
383
|
};
|
|
359
384
|
}
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
if (!
|
|
363
|
-
throw new Error(`Unknown process
|
|
364
|
-
if (
|
|
365
|
-
throw new Error(`Process
|
|
385
|
+
getOwnedProcess(workspaceId, processId) {
|
|
386
|
+
const processEntry = this.processes.get(processId);
|
|
387
|
+
if (!processEntry)
|
|
388
|
+
throw new Error(`Unknown process: ${processId}`);
|
|
389
|
+
if (processEntry.workspaceId !== workspaceId) {
|
|
390
|
+
throw new Error(`Process ${processId} does not belong to workspace ${workspaceId}.`);
|
|
366
391
|
}
|
|
367
|
-
return
|
|
392
|
+
return processEntry;
|
|
368
393
|
}
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
if (
|
|
372
|
-
clearTimeout(
|
|
373
|
-
this.
|
|
374
|
-
if (!
|
|
394
|
+
removeProcess(processId) {
|
|
395
|
+
const processEntry = this.processes.get(processId);
|
|
396
|
+
if (processEntry?.cleanupTimer)
|
|
397
|
+
clearTimeout(processEntry.cleanupTimer);
|
|
398
|
+
this.processes.delete(processId);
|
|
399
|
+
if (!processEntry)
|
|
375
400
|
return;
|
|
376
|
-
const completed = this.completedByWorkspace.get(
|
|
401
|
+
const completed = this.completedByWorkspace.get(processEntry.workspaceId);
|
|
377
402
|
if (!completed)
|
|
378
403
|
return;
|
|
379
|
-
const remaining = completed.filter((id) => id !==
|
|
404
|
+
const remaining = completed.filter((id) => id !== processId);
|
|
380
405
|
if (remaining.length > 0)
|
|
381
|
-
this.completedByWorkspace.set(
|
|
406
|
+
this.completedByWorkspace.set(processEntry.workspaceId, remaining);
|
|
382
407
|
else
|
|
383
|
-
this.completedByWorkspace.delete(
|
|
408
|
+
this.completedByWorkspace.delete(processEntry.workspaceId);
|
|
384
409
|
}
|
|
385
410
|
}
|
|
411
|
+
/** @deprecated Use ProcessManager. */
|
|
412
|
+
export { ProcessManager as ProcessSessionManager };
|
package/dist/roots.js
CHANGED
|
@@ -33,7 +33,7 @@ export function assertAllowedPath(path, allowedRoots) {
|
|
|
33
33
|
throw new AccessDeniedError(`Path is outside allowed roots: ${path}`);
|
|
34
34
|
}
|
|
35
35
|
export function resolveAllowedPath(inputPath, cwd, allowedRoots) {
|
|
36
|
-
const absolutePath = resolve(cwd, inputPath);
|
|
36
|
+
const absolutePath = resolve(cwd, expandHomePath(inputPath));
|
|
37
37
|
return assertAllowedPath(absolutePath, allowedRoots);
|
|
38
38
|
}
|
|
39
39
|
export async function resolveCanonicalAllowedPath(inputPath, cwd, allowedRoots) {
|