@aexol/spectral 0.9.194 → 0.9.196

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.
Files changed (43) hide show
  1. package/dist/commands/serve.d.ts.map +1 -1
  2. package/dist/commands/serve.js +5 -0
  3. package/dist/extensions/ports/platform.d.ts.map +1 -1
  4. package/dist/extensions/ports/platform.js +9 -3
  5. package/dist/extensions/spectral-vision-fallback.d.ts +2 -3
  6. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  7. package/dist/extensions/spectral-vision-fallback.js +25 -20
  8. package/dist/relay/dispatcher.d.ts +6 -1
  9. package/dist/relay/dispatcher.d.ts.map +1 -1
  10. package/dist/relay/dispatcher.js +54 -0
  11. package/dist/sdk/coding-agent/core/agent-session.d.ts +4 -0
  12. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  13. package/dist/sdk/coding-agent/core/agent-session.js +3 -0
  14. package/dist/sdk/coding-agent/core/bash-jobs.d.ts +1 -0
  15. package/dist/sdk/coding-agent/core/bash-jobs.d.ts.map +1 -1
  16. package/dist/sdk/coding-agent/core/bash-jobs.js +1 -1
  17. package/dist/sdk/coding-agent/core/sdk.d.ts +3 -0
  18. package/dist/sdk/coding-agent/core/sdk.d.ts.map +1 -1
  19. package/dist/sdk/coding-agent/core/sdk.js +1 -0
  20. package/dist/sdk/coding-agent/core/tools/bash.d.ts +6 -0
  21. package/dist/sdk/coding-agent/core/tools/bash.d.ts.map +1 -1
  22. package/dist/sdk/coding-agent/core/tools/bash.js +14 -6
  23. package/dist/server/agent-bridge.d.ts +7 -3
  24. package/dist/server/agent-bridge.d.ts.map +1 -1
  25. package/dist/server/agent-bridge.js +10 -8
  26. package/dist/server/dev-port-detection.d.ts +35 -0
  27. package/dist/server/dev-port-detection.d.ts.map +1 -0
  28. package/dist/server/dev-port-detection.js +113 -0
  29. package/dist/server/dev-process-registry.d.ts +93 -0
  30. package/dist/server/dev-process-registry.d.ts.map +1 -0
  31. package/dist/server/dev-process-registry.js +421 -0
  32. package/dist/server/handlers/dev-processes.d.ts +49 -0
  33. package/dist/server/handlers/dev-processes.d.ts.map +1 -0
  34. package/dist/server/handlers/dev-processes.js +200 -0
  35. package/dist/server/session-stream.d.ts +11 -0
  36. package/dist/server/session-stream.d.ts.map +1 -1
  37. package/dist/server/session-stream.js +19 -0
  38. package/dist/server/storage.d.ts +27 -0
  39. package/dist/server/storage.d.ts.map +1 -1
  40. package/dist/server/storage.js +71 -0
  41. package/dist/server/wire.d.ts +67 -0
  42. package/dist/server/wire.d.ts.map +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,421 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { BashJobRegistry, } from "../sdk/coding-agent/core/bash-jobs.js";
5
+ import { waitForChildProcess } from "../sdk/coding-agent/utils/child-process.js";
6
+ import { getShellConfig, getShellEnv, killProcessTree } from "../sdk/coding-agent/utils/shell.js";
7
+ import { OutputAccumulator, } from "../sdk/coding-agent/core/tools/output-accumulator.js";
8
+ import { getTcpPortsByPid } from "./dev-port-detection.js";
9
+ /** Hard cap for the "newest delta" chunk sent over the wire. */
10
+ const MAX_DEV_PROCESS_OUTPUT_CHUNK = 32_000;
11
+ function defaultLabel(command) {
12
+ const firstLine = command.split("\n").find((line) => line.trim().length > 0);
13
+ if (!firstLine)
14
+ return "Background process";
15
+ const trimmed = firstLine.trim();
16
+ return trimmed.length > 60 ? `${trimmed.slice(0, 57)}…` : trimmed;
17
+ }
18
+ /**
19
+ * Machine-level registry for background bash jobs that should be visible in
20
+ * the landing "Dev servers" settings section.
21
+ *
22
+ * Extends the session-scoped `BashJobRegistry` so the existing bash tool
23
+ * factories accept it unchanged, while adding the list/summary surface and
24
+ * durable pidfile tracking under `<agentDir>/dev-processes/`.
25
+ */
26
+ export class DevProcessRegistry extends BashJobRegistry {
27
+ records = new Map();
28
+ pidDir;
29
+ listeners = new Set();
30
+ portRefresh = null;
31
+ constructor(agentDir) {
32
+ super();
33
+ this.pidDir = join(agentDir, "dev-processes");
34
+ }
35
+ /**
36
+ * Subscribe to registry events. Returns an unsubscribe function.
37
+ * Listeners are invoked synchronously and are expected to be cheap;
38
+ * a throwing listener is logged and isolated so it cannot break process
39
+ * tracking.
40
+ */
41
+ onEvent(listener) {
42
+ this.listeners.add(listener);
43
+ return () => {
44
+ this.listeners.delete(listener);
45
+ };
46
+ }
47
+ create(input) {
48
+ const job = super.create(input);
49
+ const record = {
50
+ id: job.id,
51
+ definitionId: input.definitionId,
52
+ label: input.label?.trim() ? input.label : defaultLabel(input.command),
53
+ command: input.command,
54
+ cwd: input.cwd,
55
+ status: job.status,
56
+ pid: job.pid,
57
+ ports: [],
58
+ startedAt: job.startedAt,
59
+ endedAt: job.endedAt,
60
+ exitCode: job.exitCode,
61
+ fullOutputPath: job.fullOutputPath,
62
+ output: input.output,
63
+ };
64
+ this.records.set(job.id, record);
65
+ this.writePidfile(job.id);
66
+ this.emit({
67
+ type: "dev_processes_changed",
68
+ processId: job.id,
69
+ process: this.toSummary(record),
70
+ change: "started",
71
+ });
72
+ return job;
73
+ }
74
+ markExited(id, exitCode) {
75
+ super.markExited(id, exitCode);
76
+ const record = this.records.get(id);
77
+ if (record) {
78
+ const changed = record.status !== "exited";
79
+ record.status = "exited";
80
+ record.exitCode = exitCode;
81
+ record.endedAt = Date.now();
82
+ if (changed) {
83
+ this.emit({
84
+ type: "dev_processes_changed",
85
+ processId: id,
86
+ process: this.toSummary(record),
87
+ change: "exited",
88
+ });
89
+ }
90
+ }
91
+ this.removePidfile(id);
92
+ }
93
+ markKilled(id) {
94
+ super.markKilled(id);
95
+ const record = this.records.get(id);
96
+ if (record) {
97
+ const changed = record.status !== "killed";
98
+ record.status = "killed";
99
+ record.exitCode = null;
100
+ record.endedAt = Date.now();
101
+ if (changed) {
102
+ this.emit({
103
+ type: "dev_processes_changed",
104
+ processId: id,
105
+ process: this.toSummary(record),
106
+ change: "killed",
107
+ });
108
+ }
109
+ }
110
+ this.removePidfile(id);
111
+ }
112
+ /** List all tracked processes, newest first. */
113
+ list() {
114
+ return [...this.records.values()]
115
+ .sort((a, b) => b.startedAt - a.startedAt)
116
+ .map((record) => this.toSummary(record));
117
+ }
118
+ /**
119
+ * Spawn a persistent dev process. Unlike the agent's normal background bash
120
+ * path, this process is NOT force-killed after the 30-minute hard deadline
121
+ * (`waitForChildProcess(_, 0)`). It remains killable through the registry.
122
+ */
123
+ spawnDaemon(definition) {
124
+ if (!existsSync(definition.cwd)) {
125
+ throw new Error(`Working directory does not exist: ${definition.cwd}`);
126
+ }
127
+ const { shell, args } = getShellConfig();
128
+ const output = new OutputAccumulator({
129
+ tempFilePrefix: "spectral-dev-process",
130
+ alwaysPersist: true,
131
+ });
132
+ const fullOutputPath = output.persist();
133
+ const child = spawn(shell, [...args, definition.command], {
134
+ cwd: definition.cwd,
135
+ detached: process.platform !== "win32",
136
+ env: getShellEnv(),
137
+ stdio: ["ignore", "pipe", "pipe"],
138
+ windowsHide: true,
139
+ });
140
+ const kill = () => {
141
+ if (child.pid)
142
+ killProcessTree(child.pid);
143
+ };
144
+ const job = this.create({
145
+ pid: child.pid,
146
+ command: definition.command,
147
+ cwd: definition.cwd,
148
+ output,
149
+ fullOutputPath,
150
+ kill,
151
+ definitionId: definition.definitionId,
152
+ label: definition.label,
153
+ });
154
+ child.stdout?.on("data", (data) => {
155
+ output.append(data);
156
+ this.emitOutput(job.id, data, output);
157
+ });
158
+ child.stderr?.on("data", (data) => {
159
+ output.append(data);
160
+ this.emitOutput(job.id, data, output);
161
+ });
162
+ const done = waitForChildProcess(child, 0);
163
+ void done
164
+ .then(({ code }) => {
165
+ output.finish();
166
+ this.markExited(job.id, code);
167
+ }, () => {
168
+ output.finish();
169
+ this.markKilled(job.id);
170
+ })
171
+ .finally(() => {
172
+ void output.closeTempFile();
173
+ });
174
+ const record = this.records.get(job.id);
175
+ if (!record) {
176
+ throw new Error("Dev process record was not registered");
177
+ }
178
+ this.schedulePortRefresh(1_500);
179
+ this.schedulePortRefresh(5_000);
180
+ return this.toSummary(record);
181
+ }
182
+ /**
183
+ * Re-scan listening TCP ports and update any live record whose port set
184
+ * changed. Called automatically after a new daemon binds and at the end of
185
+ * pidfile reconciliation; `GET /api/dev-processes` also refreshes eagerly.
186
+ */
187
+ async refreshPorts() {
188
+ if (this.portRefresh)
189
+ return this.portRefresh;
190
+ const run = (async () => {
191
+ try {
192
+ const byPid = await getTcpPortsByPid();
193
+ for (const record of this.records.values()) {
194
+ if ((record.status !== "running" && record.status !== "recovered") ||
195
+ record.pid === undefined ||
196
+ record.pid === null) {
197
+ continue;
198
+ }
199
+ const ports = byPid.get(record.pid) ?? [];
200
+ const current = record.ports ?? [];
201
+ const changed = current.length !== ports.length ||
202
+ current.some((port, index) => port !== ports[index]);
203
+ if (!changed)
204
+ continue;
205
+ record.ports = ports;
206
+ this.emit({
207
+ type: "dev_processes_changed",
208
+ processId: record.id,
209
+ process: this.toSummary(record),
210
+ change: "ports",
211
+ });
212
+ }
213
+ }
214
+ catch {
215
+ // Port introspection is best-effort and must never break process
216
+ // tracking (e.g. lsof/ss missing or a pid exiting mid-scan).
217
+ }
218
+ })();
219
+ this.portRefresh = run;
220
+ try {
221
+ await run;
222
+ }
223
+ finally {
224
+ this.portRefresh = null;
225
+ }
226
+ }
227
+ schedulePortRefresh(ms) {
228
+ const timer = setTimeout(() => {
229
+ void this.refreshPorts();
230
+ }, ms);
231
+ if (typeof timer.unref === "function")
232
+ timer.unref();
233
+ }
234
+ /**
235
+ * Kill an existing dev process and start a fresh one from its saved
236
+ * definition. Returns null when the process id is unknown.
237
+ */
238
+ restart(id) {
239
+ const record = this.records.get(id);
240
+ if (!record)
241
+ return null;
242
+ const definition = {
243
+ definitionId: record.definitionId,
244
+ label: record.label,
245
+ command: record.command,
246
+ cwd: record.cwd,
247
+ };
248
+ // Kill the current process tree when it is still alive. For already
249
+ // exited/killed rows this is a no-op and just clears any stale pidfile.
250
+ this.kill(id);
251
+ return this.spawnDaemon(definition);
252
+ }
253
+ /**
254
+ * Rehydrate processes from durable pidfiles after a CLI restart.
255
+ *
256
+ * A live PID becomes a killable `recovered` record whose output is no
257
+ * longer attached; a dead PID has its stale pidfile removed.
258
+ */
259
+ reconcile() {
260
+ let files;
261
+ try {
262
+ mkdirSync(this.pidDir, { recursive: true });
263
+ files = readdirSync(this.pidDir).filter((name) => name.endsWith(".pid"));
264
+ }
265
+ catch {
266
+ return;
267
+ }
268
+ for (const file of files) {
269
+ const id = file.slice(0, -".pid".length);
270
+ if (this.records.has(id))
271
+ continue;
272
+ const metadata = this.readPidfile(id);
273
+ if (!metadata || metadata.pid === undefined || metadata.pid === null) {
274
+ this.removePidfile(id);
275
+ continue;
276
+ }
277
+ if (!this.isPidAlive(metadata.pid)) {
278
+ this.removePidfile(id);
279
+ continue;
280
+ }
281
+ const output = new OutputAccumulator({
282
+ tempFilePrefix: "spectral-dev-process",
283
+ });
284
+ output.append(Buffer.from("recovered — restart to reattach output\n", "utf8"));
285
+ output.finish();
286
+ const command = metadata.command?.trim() || "(recovered process)";
287
+ const label = metadata.label?.trim()
288
+ ? `${metadata.label} (recovered)`
289
+ : command !== "(recovered process)"
290
+ ? `${defaultLabel(command)} (recovered)`
291
+ : "Recovered dev process";
292
+ this.create({
293
+ id,
294
+ pid: metadata.pid,
295
+ command,
296
+ cwd: metadata.cwd ?? "",
297
+ output,
298
+ fullOutputPath: metadata.fullOutputPath ?? "",
299
+ kill: () => killProcessTree(metadata.pid),
300
+ definitionId: metadata.definitionId,
301
+ label,
302
+ });
303
+ const record = this.records.get(id);
304
+ if (record) {
305
+ record.status = "recovered";
306
+ this.emit({
307
+ type: "dev_processes_changed",
308
+ processId: id,
309
+ process: this.toSummary(record),
310
+ change: "status",
311
+ });
312
+ }
313
+ }
314
+ void this.refreshPorts();
315
+ }
316
+ toSummary(record) {
317
+ return {
318
+ id: record.id,
319
+ definitionId: record.definitionId,
320
+ label: record.label,
321
+ command: record.command,
322
+ cwd: record.cwd,
323
+ status: record.status,
324
+ pid: record.pid,
325
+ ports: record.ports ?? [],
326
+ startedAt: record.startedAt,
327
+ endedAt: record.endedAt,
328
+ exitCode: record.exitCode,
329
+ lastOutputTail: record.output.snapshot().content,
330
+ fullOutputPath: record.fullOutputPath,
331
+ };
332
+ }
333
+ emitOutput(id, chunk, output) {
334
+ const record = this.records.get(id);
335
+ if (!record)
336
+ return;
337
+ const raw = chunk.toString("utf8");
338
+ const boundedChunk = raw.length > MAX_DEV_PROCESS_OUTPUT_CHUNK
339
+ ? raw.slice(0, MAX_DEV_PROCESS_OUTPUT_CHUNK)
340
+ : raw;
341
+ this.emit({
342
+ type: "dev_process_output",
343
+ processId: id,
344
+ chunk: boundedChunk,
345
+ tail: output.snapshot().content,
346
+ });
347
+ }
348
+ emit(event) {
349
+ for (const listener of [...this.listeners]) {
350
+ try {
351
+ listener(event);
352
+ }
353
+ catch (err) {
354
+ console.error(`[spectral] error: dev-process event listener failed: ${err instanceof Error ? err.message : String(err)}`);
355
+ }
356
+ }
357
+ }
358
+ pidfilePath(id) {
359
+ return join(this.pidDir, `${id}.pid`);
360
+ }
361
+ writePidfile(id) {
362
+ const record = this.records.get(id);
363
+ if (!record)
364
+ return;
365
+ try {
366
+ mkdirSync(this.pidDir, { recursive: true });
367
+ const metadata = {
368
+ pid: record.pid,
369
+ command: record.command,
370
+ cwd: record.cwd,
371
+ label: record.label,
372
+ fullOutputPath: record.fullOutputPath,
373
+ definitionId: record.definitionId,
374
+ };
375
+ writeFileSync(this.pidfilePath(id), JSON.stringify(metadata), "utf8");
376
+ }
377
+ catch {
378
+ // Pidfiles are best-effort metadata; never break a background job
379
+ // because the dev-processes directory could not be written.
380
+ }
381
+ }
382
+ readPidfile(id) {
383
+ try {
384
+ const raw = readFileSync(this.pidfilePath(id), "utf8").trim();
385
+ if (!raw)
386
+ return null;
387
+ if (raw.startsWith("{")) {
388
+ const parsed = JSON.parse(raw);
389
+ if (typeof parsed.pid === "number" &&
390
+ Number.isInteger(parsed.pid) &&
391
+ parsed.pid > 0) {
392
+ return parsed;
393
+ }
394
+ return null;
395
+ }
396
+ // Backwards-compatible Phase 1 pidfiles contained a bare PID.
397
+ const pid = Number(raw);
398
+ return Number.isInteger(pid) && pid > 0 ? { pid } : null;
399
+ }
400
+ catch {
401
+ return null;
402
+ }
403
+ }
404
+ removePidfile(id) {
405
+ try {
406
+ unlinkSync(this.pidfilePath(id));
407
+ }
408
+ catch {
409
+ // Already gone is fine.
410
+ }
411
+ }
412
+ isPidAlive(pid) {
413
+ try {
414
+ process.kill(pid, 0);
415
+ return true;
416
+ }
417
+ catch (err) {
418
+ return err.code !== "ESRCH";
419
+ }
420
+ }
421
+ }
@@ -0,0 +1,49 @@
1
+ import type { DevProcessRegistry, DevProcessSummary } from "../dev-process-registry.js";
2
+ import type { SessionStore } from "../storage.js";
3
+ import type { DetectedDevServer } from "../wire.js";
4
+ /**
5
+ * `GET /api/dev-processes` — list machine-level background dev processes.
6
+ *
7
+ * Returns an empty list when the registry has not been constructed yet (tests
8
+ * or a minimal serve setup), matching the MCP-status handler's defensive
9
+ * no-op pattern.
10
+ */
11
+ export declare function handleListDevProcesses(registry: DevProcessRegistry | undefined): Promise<DevProcessSummary[]>;
12
+ /**
13
+ * `DELETE /api/dev-processes/:id` — kill a machine-level dev process.
14
+ *
15
+ * Throws `NotFoundError` (404) when the process is not tracked by the
16
+ * registry, so the relay dispatcher can return the canonical error payload.
17
+ */
18
+ export declare function handleKillDevProcess(registry: DevProcessRegistry | undefined, id: string): {
19
+ ok: true;
20
+ };
21
+ /**
22
+ * `POST /api/dev-processes` — persist a dev-server definition and spawn it.
23
+ *
24
+ * Security model for the MVP: `cwd` must be an absolute directory that is
25
+ * either a known project path or resolves under the serve cwd. `command` must
26
+ * have no shell metacharacters AND must either be an already-persisted
27
+ * definition or match a fixed allow-list of dev commands.
28
+ */
29
+ export declare function handleStartDevProcess(registry: DevProcessRegistry | undefined, store: SessionStore, body: Record<string, unknown>, serveCwd?: string): DevProcessSummary;
30
+ /**
31
+ * `POST /api/dev-processes/:id/restart` — kill a dev process and re-spawn it
32
+ * from the definition captured by its live record.
33
+ */
34
+ export declare function handleRestartDevProcess(registry: DevProcessRegistry | undefined, id: string): DevProcessSummary;
35
+ /**
36
+ * `GET /api/dev-servers` — scan the machine's listening TCP ports and match
37
+ * them against known projects. Includes servers the agent did not start.
38
+ */
39
+ export declare function handleScanDevServers(store: SessionStore, registry?: DevProcessRegistry): Promise<DetectedDevServer[]>;
40
+ /**
41
+ * `DELETE /api/dev-servers/:pid` — kill a detected dev server.
42
+ *
43
+ * Only servers whose cwd resolves to a known project may be killed, mirroring
44
+ * the project-ownership guard used by the tracked dev-process handlers.
45
+ */
46
+ export declare function handleKillDetectedDevServer(store: SessionStore, pid: number, port?: number): Promise<{
47
+ ok: true;
48
+ }>;
49
+ //# sourceMappingURL=dev-processes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-processes.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/dev-processes.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACX,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,4BAA4B,CAAC;AAMpC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAmGpD;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC3C,QAAQ,EAAE,kBAAkB,GAAG,SAAS,GACtC,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAG9B;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CACnC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,EAAE,EAAE,MAAM,GACR;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAKd;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACpC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,KAAK,EAAE,YAAY,EACnB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,QAAQ,CAAC,EAAE,MAAM,GACf,iBAAiB,CA+BnB;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACtC,QAAQ,EAAE,kBAAkB,GAAG,SAAS,EACxC,EAAE,EAAE,MAAM,GACR,iBAAiB,CASnB;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACzC,KAAK,EAAE,YAAY,EACnB,QAAQ,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAkB9B;AAED;;;;;GAKG;AACH,wBAAsB,2BAA2B,CAChD,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,MAAM,GACX,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,CAAC,CA8BvB"}
@@ -0,0 +1,200 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import { findProjectForCwd, listDetectedDevServers, resolvePidCwd, } from "../dev-port-detection.js";
4
+ import { BadRequestError, NotFoundError } from "./errors.js";
5
+ const ALLOWED_COMMAND_PREFIXES = [
6
+ "npm run dev",
7
+ "npm run start",
8
+ "npm start",
9
+ "pnpm dev",
10
+ "pnpm start",
11
+ "yarn dev",
12
+ "yarn start",
13
+ "bun dev",
14
+ "bun start",
15
+ "deno task dev",
16
+ "cargo run",
17
+ "go run",
18
+ "make dev",
19
+ "python manage.py runserver",
20
+ ];
21
+ function hasShellMetacharacters(command) {
22
+ return (/[;&|`><\r\n]/.test(command) ||
23
+ command.includes("$(") ||
24
+ command.includes("${"));
25
+ }
26
+ function isAllowedCommand(command) {
27
+ return ALLOWED_COMMAND_PREFIXES.some((prefix) => command === prefix || command.startsWith(`${prefix} `));
28
+ }
29
+ function killDetectedServerProcess(pid) {
30
+ try {
31
+ if (process.platform === "win32") {
32
+ process.kill(pid);
33
+ }
34
+ else {
35
+ process.kill(pid, "SIGTERM");
36
+ }
37
+ }
38
+ catch {
39
+ // Process already exited.
40
+ }
41
+ }
42
+ function validateCwd(rawCwd, store, serveCwd) {
43
+ if (typeof rawCwd !== "string" || rawCwd.trim() === "") {
44
+ throw new BadRequestError("Invalid cwd");
45
+ }
46
+ if (!isAbsolute(rawCwd)) {
47
+ throw new BadRequestError("Invalid cwd");
48
+ }
49
+ const resolved = resolve(rawCwd);
50
+ try {
51
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
52
+ throw new BadRequestError("Invalid cwd");
53
+ }
54
+ }
55
+ catch (err) {
56
+ if (err instanceof BadRequestError)
57
+ throw err;
58
+ throw new BadRequestError("Invalid cwd");
59
+ }
60
+ const isKnownProject = store
61
+ .listProjects()
62
+ .some((project) => resolve(project.path) === resolved);
63
+ if (isKnownProject)
64
+ return resolved;
65
+ if (serveCwd) {
66
+ const base = resolve(serveCwd);
67
+ const rel = relative(base, resolved);
68
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
69
+ return resolved;
70
+ }
71
+ }
72
+ throw new BadRequestError("Invalid cwd");
73
+ }
74
+ function validateCommand(command, store, cwd) {
75
+ if (typeof command !== "string" || command.trim() === "") {
76
+ throw new BadRequestError("command is required");
77
+ }
78
+ if (hasShellMetacharacters(command)) {
79
+ throw new BadRequestError("Invalid command");
80
+ }
81
+ const persisted = store.getDevProcessDefinitionByCommand(cwd, command);
82
+ if (!persisted && !isAllowedCommand(command)) {
83
+ throw new BadRequestError("Invalid command");
84
+ }
85
+ return command;
86
+ }
87
+ /**
88
+ * `GET /api/dev-processes` — list machine-level background dev processes.
89
+ *
90
+ * Returns an empty list when the registry has not been constructed yet (tests
91
+ * or a minimal serve setup), matching the MCP-status handler's defensive
92
+ * no-op pattern.
93
+ */
94
+ export async function handleListDevProcesses(registry) {
95
+ await registry?.refreshPorts();
96
+ return registry?.list() ?? [];
97
+ }
98
+ /**
99
+ * `DELETE /api/dev-processes/:id` — kill a machine-level dev process.
100
+ *
101
+ * Throws `NotFoundError` (404) when the process is not tracked by the
102
+ * registry, so the relay dispatcher can return the canonical error payload.
103
+ */
104
+ export function handleKillDevProcess(registry, id) {
105
+ if (!registry || !registry.kill(id)) {
106
+ throw new NotFoundError(`Dev process ${id} not found`);
107
+ }
108
+ return { ok: true };
109
+ }
110
+ /**
111
+ * `POST /api/dev-processes` — persist a dev-server definition and spawn it.
112
+ *
113
+ * Security model for the MVP: `cwd` must be an absolute directory that is
114
+ * either a known project path or resolves under the serve cwd. `command` must
115
+ * have no shell metacharacters AND must either be an already-persisted
116
+ * definition or match a fixed allow-list of dev commands.
117
+ */
118
+ export function handleStartDevProcess(registry, store, body, serveCwd) {
119
+ if (!registry) {
120
+ throw new BadRequestError("Dev process registry unavailable");
121
+ }
122
+ const cwd = validateCwd(body.cwd, store, serveCwd);
123
+ const command = validateCommand(body.command, store, cwd);
124
+ const label = typeof body.label === "string" && body.label.trim() !== ""
125
+ ? body.label.trim()
126
+ : undefined;
127
+ const project = store
128
+ .listProjects()
129
+ .find((candidate) => resolve(candidate.path) === cwd);
130
+ const persisted = store.getDevProcessDefinitionByCommand(cwd, command);
131
+ const definition = persisted ??
132
+ store.createDevProcessDefinition({
133
+ projectId: project?.id ?? null,
134
+ label: label ?? command,
135
+ command,
136
+ cwd,
137
+ });
138
+ return registry.spawnDaemon({
139
+ definitionId: definition.id,
140
+ label: label ?? definition.label,
141
+ command,
142
+ cwd,
143
+ });
144
+ }
145
+ /**
146
+ * `POST /api/dev-processes/:id/restart` — kill a dev process and re-spawn it
147
+ * from the definition captured by its live record.
148
+ */
149
+ export function handleRestartDevProcess(registry, id) {
150
+ if (!registry) {
151
+ throw new NotFoundError(`Dev process ${id} not found`);
152
+ }
153
+ const restarted = registry.restart(id);
154
+ if (!restarted) {
155
+ throw new NotFoundError(`Dev process ${id} not found`);
156
+ }
157
+ return restarted;
158
+ }
159
+ /**
160
+ * `GET /api/dev-servers` — scan the machine's listening TCP ports and match
161
+ * them against known projects. Includes servers the agent did not start.
162
+ */
163
+ export async function handleScanDevServers(store, registry) {
164
+ const servers = await listDetectedDevServers(store.listProjects());
165
+ if (!registry)
166
+ return servers;
167
+ const trackedPids = new Set(registry
168
+ .list()
169
+ .filter((process) => process.status === "running" || process.status === "recovered")
170
+ .map((process) => process.pid)
171
+ .filter((pid) => typeof pid === "number"));
172
+ return servers.filter((server) => server.pid === null || !trackedPids.has(server.pid));
173
+ }
174
+ /**
175
+ * `DELETE /api/dev-servers/:pid` — kill a detected dev server.
176
+ *
177
+ * Only servers whose cwd resolves to a known project may be killed, mirroring
178
+ * the project-ownership guard used by the tracked dev-process handlers.
179
+ */
180
+ export async function handleKillDetectedDevServer(store, pid, port) {
181
+ if (!Number.isInteger(pid) || pid <= 0) {
182
+ throw new BadRequestError("Invalid pid");
183
+ }
184
+ const servers = await listDetectedDevServers(store.listProjects());
185
+ const match = servers.find((server) => server.pid === pid &&
186
+ (port === undefined || !Number.isFinite(port) || server.port === port));
187
+ if (!match) {
188
+ throw new NotFoundError(`Dev server with pid ${pid} not found`);
189
+ }
190
+ if (!match.projectId || !match.cwd) {
191
+ throw new BadRequestError("Only dev servers belonging to a known project can be killed");
192
+ }
193
+ const currentCwd = await resolvePidCwd(pid);
194
+ const currentProject = findProjectForCwd(currentCwd, store.listProjects());
195
+ if (!currentProject) {
196
+ throw new BadRequestError("Server process changed or is no longer tied to a known project");
197
+ }
198
+ killDetectedServerProcess(pid);
199
+ return { ok: true };
200
+ }