@neuralnomads/codenomad 0.8.1 → 0.9.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.
@@ -30,6 +30,26 @@ export class FileSystemBrowser {
30
30
  }
31
31
  return this.listRestrictedWithMetadata(targetPath, includeFiles);
32
32
  }
33
+ createFolder(parentPath, folderName) {
34
+ const name = this.normalizeFolderName(folderName);
35
+ if (this.unrestricted) {
36
+ const resolvedParent = this.resolveUnrestrictedPath(parentPath);
37
+ if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
38
+ throw new Error("Cannot create folders at drive root");
39
+ }
40
+ this.assertDirectoryExists(resolvedParent);
41
+ const absolutePath = this.resolveAbsoluteChild(resolvedParent, name);
42
+ fs.mkdirSync(absolutePath);
43
+ return { path: absolutePath, absolutePath };
44
+ }
45
+ const normalizedParent = this.normalizeRelativePath(parentPath);
46
+ const parentAbsolute = this.toRestrictedAbsolute(normalizedParent);
47
+ this.assertDirectoryExists(parentAbsolute);
48
+ const relativePath = this.buildRelativePath(normalizedParent, name);
49
+ const absolutePath = this.toRestrictedAbsolute(relativePath);
50
+ fs.mkdirSync(absolutePath);
51
+ return { path: relativePath, absolutePath };
52
+ }
33
53
  readFile(relativePath) {
34
54
  if (this.unrestricted) {
35
55
  throw new Error("readFile is not available in unrestricted mode");
@@ -117,6 +137,34 @@ export class FileSystemBrowser {
117
137
  };
118
138
  return { entries, metadata };
119
139
  }
140
+ normalizeFolderName(input) {
141
+ const name = input.trim();
142
+ if (!name) {
143
+ throw new Error("Folder name is required");
144
+ }
145
+ if (name === "." || name === "..") {
146
+ throw new Error("Invalid folder name");
147
+ }
148
+ if (name.startsWith("~")) {
149
+ throw new Error("Invalid folder name");
150
+ }
151
+ if (name.includes("/") || name.includes("\\")) {
152
+ throw new Error("Folder name must not include path separators");
153
+ }
154
+ if (name.includes("\u0000")) {
155
+ throw new Error("Invalid folder name");
156
+ }
157
+ return name;
158
+ }
159
+ assertDirectoryExists(directory) {
160
+ if (!fs.existsSync(directory)) {
161
+ throw new Error(`Directory does not exist: ${directory}`);
162
+ }
163
+ const stats = fs.statSync(directory);
164
+ if (!stats.isDirectory()) {
165
+ throw new Error(`Path is not a directory: ${directory}`);
166
+ }
167
+ }
120
168
  readDirectoryEntries(directory, options) {
121
169
  const dirents = fs.readdirSync(directory, { withFileTypes: true });
122
170
  const results = [];
@@ -3,6 +3,10 @@ const FilesystemQuerySchema = z.object({
3
3
  path: z.string().optional(),
4
4
  includeFiles: z.coerce.boolean().optional(),
5
5
  });
6
+ const FilesystemCreateFolderSchema = z.object({
7
+ parentPath: z.string().optional(),
8
+ name: z.string(),
9
+ });
6
10
  export function registerFilesystemRoutes(app, deps) {
7
11
  app.get("/api/filesystem", async (request, reply) => {
8
12
  const query = FilesystemQuerySchema.parse(request.query ?? {});
@@ -16,4 +20,24 @@ export function registerFilesystemRoutes(app, deps) {
16
20
  return { error: error.message };
17
21
  }
18
22
  });
23
+ app.post("/api/filesystem/folders", async (request, reply) => {
24
+ const body = FilesystemCreateFolderSchema.parse(request.body ?? {});
25
+ try {
26
+ const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name);
27
+ reply.code(201);
28
+ return created;
29
+ }
30
+ catch (error) {
31
+ const err = error;
32
+ if (err?.code === "EEXIST") {
33
+ reply.code(409).type("text/plain").send("Folder already exists");
34
+ return;
35
+ }
36
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
37
+ reply.code(403).type("text/plain").send("Permission denied");
38
+ return;
39
+ }
40
+ reply.code(400).type("text/plain").send(error.message);
41
+ }
42
+ });
19
43
  }
@@ -1,4 +1,4 @@
1
- import { spawn } from "child_process";
1
+ import { spawn, spawnSync } from "child_process";
2
2
  import { existsSync, statSync } from "fs";
3
3
  import path from "path";
4
4
  export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]);
@@ -83,10 +83,12 @@ export class WorkspaceRuntime {
83
83
  commandLine,
84
84
  env: redactEnvironment(env),
85
85
  }, "Launching OpenCode process");
86
+ const detached = process.platform !== "win32";
86
87
  const child = spawn(spec.command, spec.args, {
87
88
  cwd: options.folder,
88
89
  env,
89
90
  stdio: ["ignore", "pipe", "pipe"],
91
+ detached,
90
92
  ...spec.options,
91
93
  });
92
94
  const managed = { child, requestedStop: false };
@@ -202,10 +204,90 @@ export class WorkspaceRuntime {
202
204
  managed.requestedStop = true;
203
205
  const child = managed.child;
204
206
  this.logger.info({ workspaceId }, "Stopping OpenCode process");
207
+ const pid = child.pid;
208
+ if (!pid) {
209
+ this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop");
210
+ return;
211
+ }
212
+ const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null;
213
+ const tryKillPosixGroup = (signal) => {
214
+ try {
215
+ // Negative PID targets the process group (POSIX).
216
+ process.kill(-pid, signal);
217
+ return true;
218
+ }
219
+ catch (error) {
220
+ const err = error;
221
+ if (err?.code === "ESRCH") {
222
+ return true;
223
+ }
224
+ this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group");
225
+ return false;
226
+ }
227
+ };
228
+ const tryKillSinglePid = (signal) => {
229
+ try {
230
+ process.kill(pid, signal);
231
+ return true;
232
+ }
233
+ catch (error) {
234
+ const err = error;
235
+ if (err?.code === "ESRCH") {
236
+ return true;
237
+ }
238
+ this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID");
239
+ return false;
240
+ }
241
+ };
242
+ const tryTaskkill = (force) => {
243
+ const args = ["/PID", String(pid), "/T"];
244
+ if (force) {
245
+ args.push("/F");
246
+ }
247
+ try {
248
+ const result = spawnSync("taskkill", args, { encoding: "utf8" });
249
+ const exitCode = result.status;
250
+ if (exitCode === 0) {
251
+ return true;
252
+ }
253
+ // If the PID is already gone, treat it as success.
254
+ const stderr = (result.stderr ?? "").toString().toLowerCase();
255
+ const stdout = (result.stdout ?? "").toString().toLowerCase();
256
+ const combined = `${stdout}\n${stderr}`;
257
+ if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) {
258
+ return true;
259
+ }
260
+ this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed");
261
+ return false;
262
+ }
263
+ catch (error) {
264
+ this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute");
265
+ return false;
266
+ }
267
+ };
268
+ const sendStopSignal = (signal) => {
269
+ if (process.platform === "win32") {
270
+ // Best-effort: terminate the whole process tree rooted at pid.
271
+ // Use /F only for escalation.
272
+ tryTaskkill(signal === "SIGKILL");
273
+ return;
274
+ }
275
+ // Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
276
+ const groupOk = tryKillPosixGroup(signal);
277
+ if (!groupOk) {
278
+ // Fallback to direct PID kill.
279
+ tryKillSinglePid(signal);
280
+ }
281
+ };
205
282
  await new Promise((resolve, reject) => {
283
+ let escalationTimer = null;
206
284
  const cleanup = () => {
207
285
  child.removeListener("exit", onExit);
208
286
  child.removeListener("error", onError);
287
+ if (escalationTimer) {
288
+ clearTimeout(escalationTimer);
289
+ escalationTimer = null;
290
+ }
209
291
  };
210
292
  const onExit = () => {
211
293
  cleanup();
@@ -215,30 +297,24 @@ export class WorkspaceRuntime {
215
297
  cleanup();
216
298
  reject(error);
217
299
  };
218
- const resolveIfAlreadyExited = () => {
219
- if (child.exitCode !== null || child.signalCode !== null) {
220
- this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited");
221
- cleanup();
222
- resolve();
223
- return true;
224
- }
225
- return false;
226
- };
227
- child.once("exit", onExit);
228
- child.once("error", onError);
229
- if (resolveIfAlreadyExited()) {
300
+ if (isAlreadyExited()) {
301
+ this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited");
302
+ cleanup();
303
+ resolve();
230
304
  return;
231
305
  }
232
- this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process");
233
- child.kill("SIGTERM");
234
- setTimeout(() => {
235
- if (!child.killed) {
236
- this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing");
237
- child.kill("SIGKILL");
238
- }
239
- else {
240
- this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout");
306
+ child.once("exit", onExit);
307
+ child.once("error", onError);
308
+ this.logger.debug({ workspaceId, pid, detached: process.platform !== "win32" }, "Sending SIGTERM to workspace process (tree/group)");
309
+ sendStopSignal("SIGTERM");
310
+ escalationTimer = setTimeout(() => {
311
+ escalationTimer = null;
312
+ if (isAlreadyExited()) {
313
+ this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation");
314
+ return;
241
315
  }
316
+ this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating");
317
+ sendStopSignal("SIGKILL");
242
318
  }, 2000);
243
319
  });
244
320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuralnomads/codenomad",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "CodeNomad Server",
5
5
  "author": {
6
6
  "name": "Neural Nomads",