@neuralnomads/codenomad 0.4.0 → 0.5.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.
@@ -0,0 +1,364 @@
1
+ import { spawn } from "child_process";
2
+ import { createWriteStream, existsSync, promises as fs } from "fs";
3
+ import path from "path";
4
+ import { randomBytes } from "crypto";
5
+ const ROOT_DIR = ".codenomad/background_processes";
6
+ const INDEX_FILE = "index.json";
7
+ const OUTPUT_FILE = "output.txt";
8
+ const STOP_TIMEOUT_MS = 2000;
9
+ const MAX_OUTPUT_BYTES = 20 * 1024;
10
+ const OUTPUT_PUBLISH_INTERVAL_MS = 1000;
11
+ export class BackgroundProcessManager {
12
+ constructor(deps) {
13
+ this.deps = deps;
14
+ this.running = new Map();
15
+ this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId));
16
+ this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id));
17
+ }
18
+ async list(workspaceId) {
19
+ const records = await this.readIndex(workspaceId);
20
+ const enriched = await Promise.all(records.map(async (record) => ({
21
+ ...record,
22
+ outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
23
+ })));
24
+ return enriched;
25
+ }
26
+ async start(workspaceId, title, command) {
27
+ const workspace = this.deps.workspaceManager.get(workspaceId);
28
+ if (!workspace) {
29
+ throw new Error("Workspace not found");
30
+ }
31
+ const id = this.generateId();
32
+ const processDir = await this.ensureProcessDir(workspaceId, id);
33
+ const outputPath = path.join(processDir, OUTPUT_FILE);
34
+ const outputStream = createWriteStream(outputPath, { flags: "a" });
35
+ const child = spawn("bash", ["-c", command], {
36
+ cwd: workspace.path,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ });
39
+ const record = {
40
+ id,
41
+ workspaceId,
42
+ title,
43
+ command,
44
+ cwd: workspace.path,
45
+ status: "running",
46
+ pid: child.pid,
47
+ startedAt: new Date().toISOString(),
48
+ outputSizeBytes: 0,
49
+ };
50
+ const exitPromise = new Promise((resolve) => {
51
+ child.on("close", async (code) => {
52
+ await new Promise((resolve) => outputStream.end(resolve));
53
+ this.running.delete(id);
54
+ record.status = this.statusFromExit(code);
55
+ record.exitCode = code === null ? undefined : code;
56
+ record.stoppedAt = new Date().toISOString();
57
+ await this.upsertIndex(workspaceId, record);
58
+ record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id);
59
+ this.publishUpdate(workspaceId, record);
60
+ resolve();
61
+ });
62
+ });
63
+ this.running.set(id, { child, outputPath, exitPromise, workspaceId });
64
+ let lastPublishAt = 0;
65
+ const maybePublishSize = () => {
66
+ const now = Date.now();
67
+ if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
68
+ return;
69
+ }
70
+ lastPublishAt = now;
71
+ this.publishUpdate(workspaceId, record);
72
+ };
73
+ child.stdout?.on("data", (data) => {
74
+ outputStream.write(data);
75
+ record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length;
76
+ maybePublishSize();
77
+ });
78
+ child.stderr?.on("data", (data) => {
79
+ outputStream.write(data);
80
+ record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length;
81
+ maybePublishSize();
82
+ });
83
+ await this.upsertIndex(workspaceId, record);
84
+ record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id);
85
+ this.publishUpdate(workspaceId, record);
86
+ return record;
87
+ }
88
+ async stop(workspaceId, processId) {
89
+ const record = await this.findProcess(workspaceId, processId);
90
+ if (!record) {
91
+ return null;
92
+ }
93
+ const running = this.running.get(processId);
94
+ if (running?.child && !running.child.killed) {
95
+ running.child.kill("SIGTERM");
96
+ await this.waitForExit(running);
97
+ }
98
+ if (record.status === "running") {
99
+ record.status = "stopped";
100
+ record.stoppedAt = new Date().toISOString();
101
+ await this.upsertIndex(workspaceId, record);
102
+ record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id);
103
+ this.publishUpdate(workspaceId, record);
104
+ }
105
+ return record;
106
+ }
107
+ async terminate(workspaceId, processId) {
108
+ const record = await this.findProcess(workspaceId, processId);
109
+ if (!record)
110
+ return;
111
+ const running = this.running.get(processId);
112
+ if (running?.child && !running.child.killed) {
113
+ running.child.kill("SIGTERM");
114
+ await this.waitForExit(running);
115
+ }
116
+ await this.removeFromIndex(workspaceId, processId);
117
+ await this.removeProcessDir(workspaceId, processId);
118
+ this.deps.eventBus.publish({
119
+ type: "instance.event",
120
+ instanceId: workspaceId,
121
+ event: { type: "background.process.removed", properties: { processId } },
122
+ });
123
+ }
124
+ async readOutput(workspaceId, processId, options) {
125
+ const outputPath = this.getOutputPath(workspaceId, processId);
126
+ if (!existsSync(outputPath)) {
127
+ return { id: processId, content: "", truncated: false, sizeBytes: 0 };
128
+ }
129
+ const stats = await fs.stat(outputPath);
130
+ const sizeBytes = stats.size;
131
+ const method = options.method ?? "full";
132
+ const lineCount = options.lines ?? 10;
133
+ const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes);
134
+ let content = raw;
135
+ switch (method) {
136
+ case "head":
137
+ content = this.headLines(raw, lineCount);
138
+ break;
139
+ case "tail":
140
+ content = this.tailLines(raw, lineCount);
141
+ break;
142
+ case "grep":
143
+ if (!options.pattern) {
144
+ throw new Error("Pattern is required for grep output");
145
+ }
146
+ content = this.grepLines(raw, options.pattern);
147
+ break;
148
+ default:
149
+ content = raw;
150
+ }
151
+ const effectiveMaxBytes = options.maxBytes;
152
+ return {
153
+ id: processId,
154
+ content,
155
+ truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
156
+ sizeBytes,
157
+ };
158
+ }
159
+ async streamOutput(workspaceId, processId, reply) {
160
+ const outputPath = this.getOutputPath(workspaceId, processId);
161
+ if (!existsSync(outputPath)) {
162
+ reply.code(404).send({ error: "Output not found" });
163
+ return;
164
+ }
165
+ reply.raw.setHeader("Content-Type", "text/event-stream");
166
+ reply.raw.setHeader("Cache-Control", "no-cache");
167
+ reply.raw.setHeader("Connection", "keep-alive");
168
+ reply.raw.flushHeaders?.();
169
+ reply.hijack();
170
+ const file = await fs.open(outputPath, "r");
171
+ let position = (await file.stat()).size;
172
+ const tick = async () => {
173
+ const stats = await file.stat();
174
+ if (stats.size <= position)
175
+ return;
176
+ const length = stats.size - position;
177
+ const buffer = Buffer.alloc(length);
178
+ await file.read(buffer, 0, length, position);
179
+ position = stats.size;
180
+ const content = buffer.toString("utf-8");
181
+ reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`);
182
+ };
183
+ const interval = setInterval(() => {
184
+ tick().catch((error) => {
185
+ this.deps.logger.warn({ err: error }, "Failed to stream background process output");
186
+ });
187
+ }, 1000);
188
+ const close = () => {
189
+ clearInterval(interval);
190
+ file.close().catch(() => undefined);
191
+ reply.raw.end?.();
192
+ };
193
+ reply.raw.on("close", close);
194
+ reply.raw.on("error", close);
195
+ }
196
+ async cleanupWorkspace(workspaceId) {
197
+ for (const [, running] of this.running.entries()) {
198
+ if (running.workspaceId !== workspaceId)
199
+ continue;
200
+ running.child.kill("SIGTERM");
201
+ await this.waitForExit(running);
202
+ }
203
+ await this.removeWorkspaceDir(workspaceId);
204
+ }
205
+ async waitForExit(running) {
206
+ let resolved = false;
207
+ const timeout = setTimeout(() => {
208
+ if (!resolved) {
209
+ running.child.kill("SIGKILL");
210
+ }
211
+ }, STOP_TIMEOUT_MS);
212
+ await running.exitPromise.finally(() => {
213
+ resolved = true;
214
+ clearTimeout(timeout);
215
+ });
216
+ }
217
+ statusFromExit(code) {
218
+ if (code === null)
219
+ return "stopped";
220
+ if (code === 0)
221
+ return "stopped";
222
+ return "error";
223
+ }
224
+ async readOutputBytes(outputPath, sizeBytes, maxBytes) {
225
+ if (maxBytes === undefined || sizeBytes <= maxBytes) {
226
+ return await fs.readFile(outputPath, "utf-8");
227
+ }
228
+ const start = Math.max(0, sizeBytes - maxBytes);
229
+ const file = await fs.open(outputPath, "r");
230
+ const buffer = Buffer.alloc(sizeBytes - start);
231
+ await file.read(buffer, 0, buffer.length, start);
232
+ await file.close();
233
+ return buffer.toString("utf-8");
234
+ }
235
+ headLines(input, lines) {
236
+ const parts = input.split(/\r?\n/);
237
+ return parts.slice(0, Math.max(0, lines)).join("\n");
238
+ }
239
+ tailLines(input, lines) {
240
+ const parts = input.split(/\r?\n/);
241
+ return parts.slice(Math.max(0, parts.length - lines)).join("\n");
242
+ }
243
+ grepLines(input, pattern) {
244
+ let matcher;
245
+ try {
246
+ matcher = new RegExp(pattern);
247
+ }
248
+ catch {
249
+ throw new Error("Invalid grep pattern");
250
+ }
251
+ return input
252
+ .split(/\r?\n/)
253
+ .filter((line) => matcher.test(line))
254
+ .join("\n");
255
+ }
256
+ async ensureProcessDir(workspaceId, processId) {
257
+ const root = await this.ensureWorkspaceDir(workspaceId);
258
+ const processDir = path.join(root, processId);
259
+ await fs.mkdir(processDir, { recursive: true });
260
+ return processDir;
261
+ }
262
+ async ensureWorkspaceDir(workspaceId) {
263
+ const workspace = this.deps.workspaceManager.get(workspaceId);
264
+ if (!workspace) {
265
+ throw new Error("Workspace not found");
266
+ }
267
+ const root = path.join(workspace.path, ROOT_DIR, workspaceId);
268
+ await fs.mkdir(root, { recursive: true });
269
+ return root;
270
+ }
271
+ getOutputPath(workspaceId, processId) {
272
+ const workspace = this.deps.workspaceManager.get(workspaceId);
273
+ if (!workspace) {
274
+ throw new Error("Workspace not found");
275
+ }
276
+ return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE);
277
+ }
278
+ async findProcess(workspaceId, processId) {
279
+ const records = await this.readIndex(workspaceId);
280
+ return records.find((entry) => entry.id === processId) ?? null;
281
+ }
282
+ async readIndex(workspaceId) {
283
+ const indexPath = await this.getIndexPath(workspaceId);
284
+ if (!existsSync(indexPath))
285
+ return [];
286
+ try {
287
+ const raw = await fs.readFile(indexPath, "utf-8");
288
+ const parsed = JSON.parse(raw);
289
+ return Array.isArray(parsed) ? parsed : [];
290
+ }
291
+ catch {
292
+ return [];
293
+ }
294
+ }
295
+ async upsertIndex(workspaceId, record) {
296
+ const records = await this.readIndex(workspaceId);
297
+ const index = records.findIndex((entry) => entry.id === record.id);
298
+ if (index >= 0) {
299
+ records[index] = record;
300
+ }
301
+ else {
302
+ records.push(record);
303
+ }
304
+ await this.writeIndex(workspaceId, records);
305
+ }
306
+ async removeFromIndex(workspaceId, processId) {
307
+ const records = await this.readIndex(workspaceId);
308
+ const next = records.filter((entry) => entry.id !== processId);
309
+ await this.writeIndex(workspaceId, next);
310
+ }
311
+ async writeIndex(workspaceId, records) {
312
+ const indexPath = await this.getIndexPath(workspaceId);
313
+ await fs.mkdir(path.dirname(indexPath), { recursive: true });
314
+ await fs.writeFile(indexPath, JSON.stringify(records, null, 2));
315
+ }
316
+ async getIndexPath(workspaceId) {
317
+ const workspace = this.deps.workspaceManager.get(workspaceId);
318
+ if (!workspace) {
319
+ throw new Error("Workspace not found");
320
+ }
321
+ return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE);
322
+ }
323
+ async removeProcessDir(workspaceId, processId) {
324
+ const workspace = this.deps.workspaceManager.get(workspaceId);
325
+ if (!workspace) {
326
+ return;
327
+ }
328
+ const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId);
329
+ await fs.rm(processDir, { recursive: true, force: true });
330
+ }
331
+ async removeWorkspaceDir(workspaceId) {
332
+ const workspace = this.deps.workspaceManager.get(workspaceId);
333
+ if (!workspace) {
334
+ return;
335
+ }
336
+ const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId);
337
+ await fs.rm(workspaceDir, { recursive: true, force: true });
338
+ }
339
+ async getOutputSize(workspaceId, processId) {
340
+ const outputPath = this.getOutputPath(workspaceId, processId);
341
+ if (!existsSync(outputPath)) {
342
+ return 0;
343
+ }
344
+ try {
345
+ const stats = await fs.stat(outputPath);
346
+ return stats.size;
347
+ }
348
+ catch {
349
+ return 0;
350
+ }
351
+ }
352
+ publishUpdate(workspaceId, record) {
353
+ this.deps.eventBus.publish({
354
+ type: "instance.event",
355
+ instanceId: workspaceId,
356
+ event: { type: "background.process.updated", properties: { process: record } },
357
+ });
358
+ }
359
+ generateId() {
360
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
361
+ const random = randomBytes(3).toString("hex");
362
+ return `proc_${timestamp}_${random}`;
363
+ }
364
+ }
package/dist/index.js CHANGED
@@ -79,6 +79,16 @@ async function main() {
79
79
  const eventLogger = logger.child({ component: "events" });
80
80
  logger.info({ options }, "Starting CodeNomad CLI server");
81
81
  const eventBus = new EventBus(eventLogger);
82
+ const serverMeta = {
83
+ httpBaseUrl: `http://${options.host}:${options.port}`,
84
+ eventsUrl: `/api/events`,
85
+ host: options.host,
86
+ listeningMode: options.host === "0.0.0.0" ? "all" : "local",
87
+ port: options.port,
88
+ hostLabel: options.host,
89
+ workspaceRoot: options.rootDir,
90
+ addresses: [],
91
+ };
82
92
  const configStore = new ConfigStore(options.configPath, eventBus, configLogger);
83
93
  const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger);
84
94
  const workspaceManager = new WorkspaceManager({
@@ -87,6 +97,7 @@ async function main() {
87
97
  binaryRegistry,
88
98
  eventBus,
89
99
  logger: workspaceLogger,
100
+ getServerBaseUrl: () => serverMeta.httpBaseUrl,
90
101
  });
91
102
  const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot });
92
103
  const instanceStore = new InstanceStore();
@@ -95,16 +106,6 @@ async function main() {
95
106
  eventBus,
96
107
  logger: logger.child({ component: "instance-events" }),
97
108
  });
98
- const serverMeta = {
99
- httpBaseUrl: `http://${options.host}:${options.port}`,
100
- eventsUrl: `/api/events`,
101
- host: options.host,
102
- listeningMode: options.host === "0.0.0.0" ? "all" : "local",
103
- port: options.port,
104
- hostLabel: options.host,
105
- workspaceRoot: options.rootDir,
106
- addresses: [],
107
- };
108
109
  const releaseMonitor = startReleaseMonitor({
109
110
  currentVersion: packageJson.version,
110
111
  logger: logger.child({ component: "release-monitor" }),
@@ -0,0 +1,32 @@
1
+ # opencode-config
2
+
3
+ ## TLDR
4
+ Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
5
+
6
+ ## What it is
7
+ A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
8
+
9
+ ## How it works
10
+ - CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
11
+ - This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
12
+ - OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
13
+ - The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
14
+ - The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
15
+
16
+ ## Expectations
17
+ - Local-only bridge (no auth/token yet).
18
+ - Plugin must fail startup if it cannot connect after 3 retries.
19
+ - Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
20
+ - Keep event shapes small and explicit; use `type` + `properties` only.
21
+
22
+ ## Ideas
23
+ - Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
24
+ - Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
25
+ - Promote stable event shapes and version tags once the protocol settles.
26
+
27
+ ## Pointers
28
+ - Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
29
+ - Plugin client: `packages/opencode-config/plugin/lib/client.ts`
30
+ - Plugin server routes: `packages/server/src/server/routes/plugin.ts`
31
+ - Plugin event handling: `packages/server/src/plugins/handlers.ts`
32
+ - Workspace env injection: `packages/server/src/workspaces/manager.ts`
@@ -0,0 +1,3 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json"
3
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@codenomad/opencode-config",
3
+ "version": "0.5.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "@opencode-ai/plugin": "1.1.1"
7
+ }
8
+ }
@@ -0,0 +1,32 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin"
2
+ import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
3
+ import { createBackgroundProcessTools } from "./lib/background-process"
4
+
5
+ export async function CodeNomadPlugin(input: PluginInput) {
6
+ const config = getCodeNomadConfig()
7
+ const client = createCodeNomadClient(config)
8
+ const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
9
+
10
+ await client.startEvents((event) => {
11
+ if (event.type === "codenomad.ping") {
12
+ void client.postEvent({
13
+ type: "codenomad.pong",
14
+ properties: {
15
+ ts: Date.now(),
16
+ pingTs: (event.properties as any)?.ts,
17
+ },
18
+ }).catch(() => {})
19
+ }
20
+ })
21
+
22
+ return {
23
+ tool: {
24
+ ...backgroundProcessTools,
25
+ },
26
+ async event(input: { event: any }) {
27
+ const opencodeEvent = input?.event
28
+ if (!opencodeEvent || typeof opencodeEvent !== "object") return
29
+
30
+ },
31
+ }
32
+ }