@neuralnomads/codenomad 0.8.1 → 0.9.1

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.
@@ -12,6 +12,7 @@ const PreferencesSchema = z.object({
12
12
  lastUsedBinary: z.string().optional(),
13
13
  environmentVariables: z.record(z.string()).default({}),
14
14
  modelRecents: z.array(ModelPreferenceSchema).default([]),
15
+ modelThinkingSelections: z.record(z.string(), z.string()).default({}),
15
16
  diffViewMode: z.enum(["split", "unified"]).default("split"),
16
17
  toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
17
18
  diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
@@ -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 = [];
package/dist/index.js CHANGED
@@ -205,22 +205,32 @@ async function main() {
205
205
  return;
206
206
  }
207
207
  shuttingDown = true;
208
- logger.info("Received shutdown signal, closing server");
209
- try {
210
- await server.stop();
211
- logger.info("HTTP server stopped");
212
- }
213
- catch (error) {
214
- logger.error({ err: error }, "Failed to stop HTTP server");
215
- }
216
- try {
217
- instanceEventBridge.shutdown();
218
- await workspaceManager.shutdown();
219
- logger.info("Workspace manager shutdown complete");
220
- }
221
- catch (error) {
222
- logger.error({ err: error }, "Workspace manager shutdown failed");
223
- }
208
+ logger.info("Received shutdown signal, stopping workspaces and server");
209
+ const shutdownWorkspaces = (async () => {
210
+ try {
211
+ instanceEventBridge.shutdown();
212
+ }
213
+ catch (error) {
214
+ logger.warn({ err: error }, "Instance event bridge shutdown failed");
215
+ }
216
+ try {
217
+ await workspaceManager.shutdown();
218
+ logger.info("Workspace manager shutdown complete");
219
+ }
220
+ catch (error) {
221
+ logger.error({ err: error }, "Workspace manager shutdown failed");
222
+ }
223
+ })();
224
+ const shutdownHttp = (async () => {
225
+ try {
226
+ await server.stop();
227
+ logger.info("HTTP server stopped");
228
+ }
229
+ catch (error) {
230
+ logger.error({ err: error }, "Failed to stop HTTP server");
231
+ }
232
+ })();
233
+ await Promise.allSettled([shutdownWorkspaces, shutdownHttp]);
224
234
  // no-op: remote UI manifest replaces GitHub release monitor
225
235
  logger.info("Exiting process");
226
236
  process.exit(0);
@@ -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
  }
@@ -136,16 +136,19 @@ export class WorkspaceManager {
136
136
  }
137
137
  async shutdown() {
138
138
  this.options.logger.info("Shutting down all workspaces");
139
+ const stopTasks = [];
139
140
  for (const [id, workspace] of this.workspaces) {
140
- if (workspace.pid) {
141
- this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown");
142
- await this.runtime.stop(id).catch((error) => {
143
- this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown");
144
- });
145
- }
146
- else {
141
+ if (!workspace.pid) {
147
142
  this.options.logger.debug({ workspaceId: id }, "Workspace already stopped");
143
+ continue;
148
144
  }
145
+ this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown");
146
+ stopTasks.push(this.runtime.stop(id).catch((error) => {
147
+ this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown");
148
+ }));
149
+ }
150
+ if (stopTasks.length > 0) {
151
+ await Promise.allSettled(stopTasks);
149
152
  }
150
153
  this.workspaces.clear();
151
154
  this.opencodeAuth.clear();
@@ -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.1",
4
4
  "description": "CodeNomad Server",
5
5
  "author": {
6
6
  "name": "Neural Nomads",