From 720870b24b89d0123e9e302b1b51deb4789d9261 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:25:21 +0000 Subject: [PATCH 1/4] chore: add persistent AI coding rules Project-level rules for Conventional Commits, minimal/typed/secure code, no UI churn, and targeted-only validation. --- CLAUDE.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65223b1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,27 @@ +# AI coding rules for NyxelOS + +NyxelOS is an agentic OS (Bun/Hono/tRPC server, React/TanStack web, Drizzle +DB). These rules apply to every AI-assisted change in this repo. + +## Commits +- Conventional Commits only (`feat:`, `fix:`, `chore:`, `refactor:`, `test:`, `docs:`). +- Concise, meaningful messages — state the "why", not a change log. +- Small, focused commits. Don't bundle unrelated fixes. +- Don't commit generated noise or formatting-only diffs unless the task requires them. + +## Code +- Minimal, typed, secure, maintainable. No AI slop, no filler, no speculative features. +- No unused abstractions or wrappers "for later." Three similar lines beat a premature abstraction. +- Follow existing repo architecture and naming — no unrelated refactors. +- Never expose secrets (API keys, tokens, OAuth state) to browser/client responses. +- Avoid unsafe tool execution paths (unsandboxed eval, unchecked shell/file access) — match existing permission/approval patterns (`tools-builtin/*`, `approvals.ts`). +- Keep agent runtime behavior deterministic where possible (explicit status transitions, atomic claims over check-then-write). + +## UI +- Do not change UI/UX unless the task requires it. No decorative or visual-redesign work. +- Preserve shadcn/default-theme conventions when UI changes are required. + +## Validation +- Don't run the full test suite by default. Run targeted tests for the files/packages you touched. +- Typecheck only the changed package/app when feasible; lint only the touched area. +- Only run broad/global suites when a change touches shared core behavior and no smaller validation exists. From eaae1e5e1b388d76175b7320f00beb53a6210934 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:25:24 +0000 Subject: [PATCH 2/4] fix: truncate get_terminal_output before it enters model context terminal_output returned a live session's full buffer (up to MAX_BUFFERED_CHARS) uncapped, unlike terminal_run/task_run/test_run which already tail-slice their own output. A long-running session could blow the context budget just from re-reading its output. --- apps/server/src/tools-builtin/read.test.ts | 43 ++++++++++++++++++++++ apps/server/src/tools-builtin/read.ts | 6 ++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/tools-builtin/read.test.ts diff --git a/apps/server/src/tools-builtin/read.test.ts b/apps/server/src/tools-builtin/read.test.ts new file mode 100644 index 0000000..7c933ae --- /dev/null +++ b/apps/server/src/tools-builtin/read.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import type { ToolRecord } from "@nyxel/db"; +import { createSkillContext } from "@nyxel/skills-sdk"; +import { buildTerminalOutputTool } from "./read"; +import { buildTerminalRunTool, getTerminalSession } from "./terminal"; + +function record(overrides: Partial = {}): ToolRecord { + return { + id: "tool-1", + workspaceId: "workspace-1", + name: "Terminal output", + description: "Read buffered terminal output.", + kind: "terminal_output", + config: {}, + sensitive: false, + enabled: true, + builtin: true, + createdAt: new Date(), + ...overrides, + }; +} + +describe("buildTerminalOutputTool output cap", () => { + it("caps output to the same tail-slice budget terminal_run/task_run already apply", async () => { + const runTool = buildTerminalRunTool(record({ kind: "terminal_run" })); + const { execId } = await runTool.run( + // Each line is 100 chars + newline; 200 lines comfortably exceeds the 8000-char cap. + { command: `for i in $(seq 1 200); do printf '%0100d\\n' "$i"; done` }, + createSkillContext(runTool.permissions), + ); + + await Bun.sleep(500); + const session = getTerminalSession(execId); + if (!session) throw new Error("session not found"); + expect(session.output.length).toBeGreaterThan(8000); + + const outputTool = buildTerminalOutputTool(record()); + const result = await outputTool.run({ execId }, createSkillContext(outputTool.permissions)); + + expect(result.output.length).toBeLessThanOrEqual(8000); + expect(result.output).toBe(session.output.slice(-8000)); + }); +}); diff --git a/apps/server/src/tools-builtin/read.ts b/apps/server/src/tools-builtin/read.ts index 8b84945..494af46 100644 --- a/apps/server/src/tools-builtin/read.ts +++ b/apps/server/src/tools-builtin/read.ts @@ -121,11 +121,15 @@ export function buildTerminalOutputTool(record: ToolRecord): SkillDefinition { async run({ execId }) { const session = getTerminalSession(execId); if (!session) throw new Error(`Unknown terminal execId: ${execId}`); + // Same tail-slice cap terminal_run/task_run/test_run already apply to + // their own output — a long-running session's full buffer (up to + // MAX_BUFFERED_CHARS in terminal.ts) shouldn't go straight into the + // model's context uncapped just because it's read back separately. return { execId, status: session.status, exitCode: session.exitCode, - output: session.output, + output: session.output.slice(-8000), }; }, }; From f7e683a4f8d6795991451de6d61457e5c77d0b98 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:25:29 +0000 Subject: [PATCH 3/4] fix: prevent duplicate concurrent runs of the same automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listDueAutomations only advances nextRunAt once finishAutomationRun completes, so an automation run that outlives one 30s poll tick was still "due" on the next tick and got dispatched a second time in parallel — same race let a manual runNow overlap a poll-triggered run. Add a process-local in-flight guard (mirrors knowledge-base.ts's inFlightSyncs) so a second concurrent dispatch is skipped instead of double-running. --- apps/server/src/scheduler.test.ts | 25 ++++++++++++++++++- apps/server/src/scheduler.ts | 40 +++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/server/src/scheduler.test.ts b/apps/server/src/scheduler.test.ts index c3fddbd..f20869a 100644 --- a/apps/server/src/scheduler.test.ts +++ b/apps/server/src/scheduler.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import type { DbRepository } from "@nyxel/db"; import { getDb } from "@nyxel/db"; import { createTestUser, installTestDb } from "@nyxel/db/test-utils"; -import { checkStaleAgentRuns } from "./scheduler"; +import { checkStaleAgentRuns, runAutomation } from "./scheduler"; async function seedWorkspace(db: DbRepository, path: string) { const user = createTestUser(path); @@ -123,3 +123,26 @@ describe("checkStaleAgentRuns", () => { expect(stillCompleted?.status).toBe("completed"); }); }); + +describe("runAutomation", () => { + it("skips a concurrent dispatch of the same automation instead of running it twice", async () => { + const db = getDb(); + const { workspace } = await seedWorkspace(db, ctx.path); + const automation = await db.createAutomation({ + workspaceId: workspace.id, + agentId: null, + name: "No agent configured", + triggerType: "cron", + cronExpression: "* * * * *", + }); + + const [first, second] = await Promise.all([ + runAutomation(automation), + runAutomation(automation), + ]); + + const outputs = [first.output, second.output]; + expect(outputs).toContain("Skipped — a run for this automation is already in progress."); + expect(outputs.filter((o) => o.includes("Agent missing"))).toHaveLength(1); + }); +}); diff --git a/apps/server/src/scheduler.ts b/apps/server/src/scheduler.ts index f55b2e0..96ad085 100644 --- a/apps/server/src/scheduler.ts +++ b/apps/server/src/scheduler.ts @@ -200,6 +200,14 @@ async function runWorkflowAutomation( return { taskId: "", runId, output: outputText }; } +/** Guards against the same automation dispatching twice concurrently — e.g. + * a run that outlives one POLL_INTERVAL_MS tick is still "due" by + * `nextRunAt` (only advanced once `finishAutomationRun` completes) on the + * next tick, and `automations.runNow` could otherwise race a poll-triggered + * run of the same automation. Process-local only (mirrors knowledge-base.ts's + * `inFlightSyncs`), not a cross-replica lock. */ +const automationsInFlight = new Set(); + /** Exported for automations.runNow — lets a user trigger a run immediately * without waiting for the schedule, e.g. to test a new automation. Dispatches * on targetKind: "agent" (default, existing behavior) runs the automation's @@ -208,16 +216,28 @@ async function runWorkflowAutomation( export async function runAutomation( automation: AutomationRecord, ): Promise<{ taskId: string; runId: string; output: string }> { - await emitNyxelEvent({ - workspaceId: automation.workspaceId, - type: NyxelEvent.AutomationTriggered, - entityType: "automation", - entityId: automation.id, - payload: { triggerType: automation.triggerType, targetKind: automation.targetKind }, - }); - return automation.targetKind === "workflow" - ? runWorkflowAutomation(automation) - : runAgentAutomation(automation); + if (automationsInFlight.has(automation.id)) { + return { + taskId: "", + runId: "", + output: "Skipped — a run for this automation is already in progress.", + }; + } + automationsInFlight.add(automation.id); + try { + await emitNyxelEvent({ + workspaceId: automation.workspaceId, + type: NyxelEvent.AutomationTriggered, + entityType: "automation", + entityId: automation.id, + payload: { triggerType: automation.triggerType, targetKind: automation.targetKind }, + }); + return automation.targetKind === "workflow" + ? await runWorkflowAutomation(automation) + : await runAgentAutomation(automation); + } finally { + automationsInFlight.delete(automation.id); + } } function resolveWatchPath(watchPath: string): string { From a9eed9a0961fcc303129390e7449a289d0ca12ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 18:27:38 +0000 Subject: [PATCH 4/4] style: reformat read.ts to the project's biome config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing tabs vs. the project's 2-space format — untouched until this PR's edit to buildTerminalOutputTool, at which point CI's --changed lint gate now checks the whole file. Whitespace-only, no behavior change. --- apps/server/src/tools-builtin/read.ts | 264 +++++++++++++------------- 1 file changed, 133 insertions(+), 131 deletions(-) diff --git a/apps/server/src/tools-builtin/read.ts b/apps/server/src/tools-builtin/read.ts index 494af46..e2febb2 100644 --- a/apps/server/src/tools-builtin/read.ts +++ b/apps/server/src/tools-builtin/read.ts @@ -6,133 +6,129 @@ import { allowedDirsFromConfig, baseFields } from "./shared"; import { getLastCommand, getTerminalSession, runCommandToCompletion } from "./terminal"; const IMAGE_MIME_BY_EXT: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", }; export function buildFileStatTool(record: ToolRecord): SkillDefinition { - const allowedDirs = allowedDirsFromConfig(record.config ?? {}); - return { - ...baseFields(record), - inputSchema: z.object({ path: z.string() }), - permissions: { network: [], filesystem: allowedDirs }, - async run({ path: filePath }) { - const ctx = createSkillContext({ network: [], filesystem: allowedDirs }); - return ctx.statPath(filePath); - }, - }; + const allowedDirs = allowedDirsFromConfig(record.config ?? {}); + return { + ...baseFields(record), + inputSchema: z.object({ path: z.string() }), + permissions: { network: [], filesystem: allowedDirs }, + async run({ path: filePath }) { + const ctx = createSkillContext({ network: [], filesystem: allowedDirs }); + return ctx.statPath(filePath); + }, + }; } export function buildFileViewImageTool(record: ToolRecord): SkillDefinition { - const allowedDirs = allowedDirsFromConfig(record.config ?? {}); - return { - ...baseFields(record), - inputSchema: z.object({ path: z.string() }), - permissions: { network: [], filesystem: allowedDirs }, - async run({ path: filePath }) { - // Images are binary — skills-sdk's readFile/SkillContext only exposes - // utf-8 text reads, so this goes through raw fs directly, still gated - // by the same allow-listed directories (same check readFile itself - // uses) rather than skipping permission enforcement. - const resolved = path.resolve(filePath); - const allowed = allowedDirs.some( - (dir) => resolved === dir || resolved.startsWith(`${dir}/`), - ); - if (!allowed) { - throw new Error( - `"${resolved}" isn't in this tool's declared filesystem permissions.`, - ); - } - const { readFile } = await import("node:fs/promises"); - const buffer = await readFile(resolved); - const mimeType = IMAGE_MIME_BY_EXT[path.extname(resolved).toLowerCase()] ?? - "application/octet-stream"; - return { - path: resolved, - mimeType, - base64: buffer.toString("base64"), - bytes: buffer.length, - }; - }, - }; + const allowedDirs = allowedDirsFromConfig(record.config ?? {}); + return { + ...baseFields(record), + inputSchema: z.object({ path: z.string() }), + permissions: { network: [], filesystem: allowedDirs }, + async run({ path: filePath }) { + // Images are binary — skills-sdk's readFile/SkillContext only exposes + // utf-8 text reads, so this goes through raw fs directly, still gated + // by the same allow-listed directories (same check readFile itself + // uses) rather than skipping permission enforcement. + const resolved = path.resolve(filePath); + const allowed = allowedDirs.some((dir) => resolved === dir || resolved.startsWith(`${dir}/`)); + if (!allowed) { + throw new Error(`"${resolved}" isn't in this tool's declared filesystem permissions.`); + } + const { readFile } = await import("node:fs/promises"); + const buffer = await readFile(resolved); + const mimeType = + IMAGE_MIME_BY_EXT[path.extname(resolved).toLowerCase()] ?? "application/octet-stream"; + return { + path: resolved, + mimeType, + base64: buffer.toString("base64"), + bytes: buffer.length, + }; + }, + }; } export function buildNotebookSummaryTool(record: ToolRecord): SkillDefinition { - const allowedDirs = allowedDirsFromConfig(record.config ?? {}); - return { - ...baseFields(record), - inputSchema: z.object({ path: z.string() }), - permissions: { network: [], filesystem: allowedDirs }, - async run({ path: filePath }) { - const ctx = createSkillContext({ network: [], filesystem: allowedDirs }); - const notebook = JSON.parse(await ctx.readFile(filePath)) as { - cells: { cell_type: string; source: string[] }[]; - }; - return { - path: filePath, - cells: notebook.cells.map((cell, index) => ({ - index, - cellType: cell.cell_type, - firstLine: cell.source[0]?.slice(0, 200) ?? "", - })), - }; - }, - }; + const allowedDirs = allowedDirsFromConfig(record.config ?? {}); + return { + ...baseFields(record), + inputSchema: z.object({ path: z.string() }), + permissions: { network: [], filesystem: allowedDirs }, + async run({ path: filePath }) { + const ctx = createSkillContext({ network: [], filesystem: allowedDirs }); + const notebook = JSON.parse(await ctx.readFile(filePath)) as { + cells: { cell_type: string; source: string[] }[]; + }; + return { + path: filePath, + cells: notebook.cells.map((cell, index) => ({ + index, + cellType: cell.cell_type, + firstLine: cell.source[0]?.slice(0, 200) ?? "", + })), + }; + }, + }; } export function buildNotebookCellOutputTool(record: ToolRecord): SkillDefinition { - const allowedDirs = allowedDirsFromConfig(record.config ?? {}); - return { - ...baseFields(record), - inputSchema: z.object({ path: z.string(), index: z.number().int().min(0) }), - permissions: { network: [], filesystem: allowedDirs }, - async run({ path: filePath, index }) { - const ctx = createSkillContext({ network: [], filesystem: allowedDirs }); - const notebook = JSON.parse(await ctx.readFile(filePath)) as { - cells: { outputs?: unknown[] }[]; - }; - const cell = notebook.cells[index]; - if (!cell) throw new Error(`Cell index ${index} out of range.`); - return { path: filePath, index, outputs: cell.outputs ?? [] }; - }, - }; + const allowedDirs = allowedDirsFromConfig(record.config ?? {}); + return { + ...baseFields(record), + inputSchema: z.object({ path: z.string(), index: z.number().int().min(0) }), + permissions: { network: [], filesystem: allowedDirs }, + async run({ path: filePath, index }) { + const ctx = createSkillContext({ network: [], filesystem: allowedDirs }); + const notebook = JSON.parse(await ctx.readFile(filePath)) as { + cells: { outputs?: unknown[] }[]; + }; + const cell = notebook.cells[index]; + if (!cell) throw new Error(`Cell index ${index} out of range.`); + return { path: filePath, index, outputs: cell.outputs ?? [] }; + }, + }; } export function buildTerminalLastCommandTool(record: ToolRecord): SkillDefinition { - return { - ...baseFields(record), - inputSchema: z.object({}), - permissions: { network: [], filesystem: [] }, - async run() { - return getLastCommand() ?? { command: null, execId: null }; - }, - }; + return { + ...baseFields(record), + inputSchema: z.object({}), + permissions: { network: [], filesystem: [] }, + async run() { + return getLastCommand() ?? { command: null, execId: null }; + }, + }; } export function buildTerminalOutputTool(record: ToolRecord): SkillDefinition { - return { - ...baseFields(record), - inputSchema: z.object({ execId: z.string() }), - permissions: { network: [], filesystem: [] }, - async run({ execId }) { - const session = getTerminalSession(execId); - if (!session) throw new Error(`Unknown terminal execId: ${execId}`); - // Same tail-slice cap terminal_run/task_run/test_run already apply to - // their own output — a long-running session's full buffer (up to - // MAX_BUFFERED_CHARS in terminal.ts) shouldn't go straight into the - // model's context uncapped just because it's read back separately. - return { - execId, - status: session.status, - exitCode: session.exitCode, - output: session.output.slice(-8000), - }; - }, - }; + return { + ...baseFields(record), + inputSchema: z.object({ execId: z.string() }), + permissions: { network: [], filesystem: [] }, + async run({ execId }) { + const session = getTerminalSession(execId); + if (!session) throw new Error(`Unknown terminal execId: ${execId}`); + // Same tail-slice cap terminal_run/task_run/test_run already apply to + // their own output — a long-running session's full buffer (up to + // MAX_BUFFERED_CHARS in terminal.ts) shouldn't go straight into the + // model's context uncapped just because it's read back separately. + return { + execId, + status: session.status, + exitCode: session.exitCode, + output: session.output.slice(-8000), + }; + }, + }; } /** Runs the workspace's own type-checker/linter and parses its output into a @@ -140,26 +136,32 @@ export function buildTerminalOutputTool(record: ToolRecord): SkillDefinition { * `tsc --noEmit` (or a configured command) read after the fact, not a live * language-server diagnostics feed. */ export function buildProblemsTool(record: ToolRecord): SkillDefinition { - const configuredCommand = - typeof record.config?.command === "string" && record.config.command - ? record.config.command - : "tsc --noEmit"; - return { - ...baseFields(record), - inputSchema: z.object({ cwd: z.string().optional() }), - permissions: { network: [], filesystem: [] }, - async run({ cwd }) { - const { output, exitCode, timedOut } = await runCommandToCompletion( - configuredCommand, - cwd, - 30_000, - ); - const problems = output - .split("\n") - .map((line) => line.trim()) - .filter((line) => /:\d+:\d+/.test(line)) - .slice(0, 200); - return { command: configuredCommand, exitCode, timedOut, problems, rawOutput: output.slice(-4000) }; - }, - }; + const configuredCommand = + typeof record.config?.command === "string" && record.config.command + ? record.config.command + : "tsc --noEmit"; + return { + ...baseFields(record), + inputSchema: z.object({ cwd: z.string().optional() }), + permissions: { network: [], filesystem: [] }, + async run({ cwd }) { + const { output, exitCode, timedOut } = await runCommandToCompletion( + configuredCommand, + cwd, + 30_000, + ); + const problems = output + .split("\n") + .map((line) => line.trim()) + .filter((line) => /:\d+:\d+/.test(line)) + .slice(0, 200); + return { + command: configuredCommand, + exitCode, + timedOut, + problems, + rawOutput: output.slice(-4000), + }; + }, + }; }