diff --git a/README.md b/README.md index a7f1dba..af401cb 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,16 @@ delegate** *inside* Claude Code or Codex. ## Download -From the [**latest release**](https://github.com/VedSoni-dev/omniwork/releases/latest): +**macOS (Apple Silicon) — Homebrew:** + +```sh +brew install --cask --no-quarantine VedSoni-dev/tap/omniwork +``` + +(`--no-quarantine` skips the Gatekeeper prompt for the unsigned app; omit it if you'd +rather right-click → Open once.) + +Or grab an installer from the [**latest release**](https://github.com/VedSoni-dev/omniwork/releases/latest): | Platform | File | Size | Notes | |---|---|---|---| diff --git a/electron/agent.js b/electron/agent.js index c36d367..278b5f8 100644 --- a/electron/agent.js +++ b/electron/agent.js @@ -7,7 +7,7 @@ // with its own context/tool-loop, and report a summary back to the parent. const { TOOL_SCHEMA, executeTool } = require("./tools"); -const { MEMORY_TOOL, saveMemory, loadForPrompt } = require("./memory"); +const { MEMORY_TOOL, saveMemory, loadForPrompt, KNOWLEDGE_TOOL, knowledgeSection, readKnowledge } = require("./memory"); const { DEFAULT_CONTEXT, estimateTokens, shouldCompact, compact } = require("./compactor"); const skills = require("./skills"); @@ -97,7 +97,7 @@ class Agent { this.workspace = workspace; this.emit = emit; // (event, payload) => void this.mcp = mcp || null; - this.memory = memory; // { globalDir, projectDir } | null + this.memory = memory; // { globalDir, projectDir, knowledgeDir? } | null this.skillsDir = skillsDir || null; // global skills root this.browser = browser || null; // BrowserManager this.contextTokens = DEFAULT_CONTEXT; @@ -142,6 +142,13 @@ class Agent { const path = require("node:path"); const files = ["AGENTS.md", "CLAUDE.md", "CLAUDE.local.md", ".omniwork.md", ".cursorrules"]; let mem = ""; + // Project instructions the user set in the project page — first, they lead. + if (this.memory && this.memory.instructionsFile) { + try { + const ins = fs.readFileSync(this.memory.instructionsFile, "utf8").trim(); + if (ins) mem += `\n\n## Project instructions (set by the user — follow these)\n${ins.slice(0, 8000)}`; + } catch {} + } for (const f of files) { try { const p = path.join(this.workspace, f); @@ -152,6 +159,7 @@ class Agent { if (this.memory) { const saved = loadForPrompt(this.memory.globalDir, this.memory.projectDir); if (saved) mem += `\n\n${saved}`; + if (this.memory.knowledgeDir) mem += knowledgeSection(this.memory.knowledgeDir); } // Skills: names + descriptions only; bodies load via use_skill. if (this.skillsDir) mem += skills.promptSection(this.skillsDir, this.workspace); @@ -181,12 +189,18 @@ class Agent { const extra = this.mcp ? this.mcp.toolSchema() : []; const sub = this.canSpawn ? [SUBAGENT_TOOL] : []; const mem = this.memory ? [MEMORY_TOOL] : []; + const kn = this.memory && this.memory.knowledgeDir ? [KNOWLEDGE_TOOL] : []; const sk = this.skillsDir ? [skills.USE_SKILL_TOOL, skills.SAVE_SKILL_TOOL, skills.INSTALL_SKILLS_TOOL] : []; const web = this.browser ? [require("./browser").WEB_SEARCH_TOOL, require("./browser").BROWSE_TOOL] : []; - return [...TOOL_SCHEMA, ...sub, ...mem, ...sk, ...web, ...extra]; + return [...TOOL_SCHEMA, ...sub, ...mem, ...kn, ...sk, ...web, ...extra]; } - abort() { this.aborted = true; } + // Aborting must also kill in-flight network work — a flag alone leaves the + // user waiting out a slow or hung stream before Esc "takes". + abort() { + this.aborted = true; + try { this.abortCtl?.abort(); } catch {} + } setWorkspace(dir) { this.workspace = dir; } // One-off model call outside the session's message history (summaries, @@ -194,6 +208,7 @@ class Agent { async oneShot(prompt) { const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", + signal: AbortSignal.timeout(60_000), headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify({ model: this.model, messages: [{ role: "user", content: prompt }], temperature: 0.2 }), }); @@ -233,6 +248,7 @@ class Agent { async #requestModel(stream) { const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", + signal: this.abortCtl?.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` }, body: JSON.stringify({ model: this.model, @@ -275,6 +291,7 @@ class Agent { let usage = null; const toolCalls = []; while (true) { + if (this.aborted) { try { reader.cancel(); } catch {} break; } const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); @@ -356,6 +373,7 @@ class Agent { async send(userText, images) { this.aborted = false; + this.abortCtl = new AbortController(); this.turnUndo = new Map(); this.undoAvailable = false; this.turnStats = { startedAt: Date.now(), inTokens: 0, outTokens: 0, estimated: false }; @@ -387,7 +405,14 @@ class Agent { let msg; try { msg = await this.callModel(); } - catch (err) { this.emit("error", { message: err.message }); return; } + catch (err) { + if (this.aborted) { this.emit("aborted", this.#statsPayload()); return; } + const friendly = /fetch failed|ECONNREFUSED/i.test(err.message) + ? "Lost connection to the engine — it restarts itself within ~20s. Try again in a moment." + : err.message; + this.emit("error", { message: friendly }); + return; + } if (msg.content && msg.content.trim()) { this.lastText = msg.content; @@ -448,6 +473,8 @@ class Agent { const made = skills.createSkill(this.skillsDir, parsedArgs.name, parsedArgs.description, parsedArgs.instructions); result = `Saved skill "${made.name}" — available in every project via /` + made.name; } catch (e) { result = "Failed to save skill: " + e.message; } + } else if (name === "read_knowledge" && this.memory && this.memory.knowledgeDir) { + result = readKnowledge(this.memory.knowledgeDir, parsedArgs.name || ""); } else if (name === "save_memory" && this.memory) { const dir = parsedArgs.scope === "global" ? this.memory.globalDir : this.memory.projectDir; try { result = saveMemory(dir, parsedArgs.title || "note", parsedArgs.content || ""); } diff --git a/electron/main.js b/electron/main.js index 18067d1..a98ad7d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -10,6 +10,7 @@ const { SessionManager } = require("./sessions"); const { MCPManager } = require("./mcp"); const { ProjectManager } = require("./projects"); const { BrowserManager } = require("./browser"); +const { Scheduler } = require("./scheduler"); // Do this before anything spawns a child: a Finder/Dock launch hands us a bare // PATH, which would break `npx` MCP servers and the agent's run_command. @@ -21,6 +22,7 @@ let sessions = null; let mcp = null; let projects = null; let browser = null; +let scheduler = null; const state = { model: "auto", approval: "auto", lastWorkspace: null, gateway: { state: "boot" } }; @@ -101,6 +103,8 @@ async function boot() { // Bring up MCP servers in the background; refresh connection list when ready. mcp.startAll().then(() => send("mcp:list", { servers: mcp.list() })).catch(() => {}); installDefaultSkills(); + scheduler = new Scheduler({ projects, sessions }); + scheduler.start(); }).catch((e) => send("gateway:status", { state: "error", detail: e.message })); } @@ -131,10 +135,14 @@ ipcMain.handle("session:create", (_e, opts) => { }); ipcMain.handle("session:list", () => (sessions ? { sessions: sessions.list(), activeId: sessions.activeId } : { sessions: [], activeId: null })); ipcMain.handle("session:setActive", (_e, id) => { if (sessions) sessions.setActive(id); return true; }); -ipcMain.handle("session:transcript", (_e, id) => (sessions ? sessions.transcript(id) : [])); -ipcMain.handle("session:send", async (_e, { id, text, images }) => { +ipcMain.handle("session:transcript", (_e, id) => { + if (!sessions) return { events: [], pendingApproval: null }; + const s = sessions.sessions.get(id); + return { events: sessions.transcript(id), pendingApproval: (s && s.pendingApproval) || null }; +}); +ipcMain.handle("session:send", async (_e, { id, text, images, label }) => { if (!sessions) { send("session:event", { sessionId: id, type: "error", message: "Engine still starting." }); return false; } - await sessions.send(id, text, images); + await sessions.send(id, text, images, label); return true; }); ipcMain.handle("session:stop", (_e, id) => { if (sessions) sessions.stop(id); return true; }); @@ -197,6 +205,79 @@ ipcMain.handle("skills:new", (_e, name) => { }); ipcMain.handle("skills:open", () => { fs.mkdirSync(skillsDir(), { recursive: true }); shell.openPath(skillsDir()); return true; }); +// ── IPC: memory editing + project knowledge + project page ────────── +function memoryDirFor(scope, projectId) { + if (scope === "global" || !projects) return path.join(app.getPath("userData"), "memory"); + if (projectId && projects.byId(projectId)) return projects.memoryDir(projectId); + const s = sessions && sessions.sessions.get(sessions.activeId); + return s ? projects.memoryDir(s.projectId) : path.join(app.getPath("userData"), "memory"); +} +ipcMain.handle("memory:read", (_e, arg) => { + const { scope, projectId } = typeof arg === "object" && arg ? arg : { scope: arg }; + const { memoryFile } = require("./memory"); + const f = memoryFile(memoryDirFor(scope, projectId)); + try { return { content: fs.readFileSync(f, "utf8"), path: f }; } + catch { return { content: "", path: f }; } +}); +ipcMain.handle("memory:write", (_e, { scope, content, projectId }) => { + const { memoryFile } = require("./memory"); + const dir = memoryDirFor(scope, projectId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(memoryFile(dir), String(content ?? ""), "utf8"); + return true; +}); + +// The project page: everything about one project in a single fetch. +ipcMain.handle("project:info", (_e, id) => { + const p = projects && projects.byId(id); + if (!p) return null; + const { listKnowledge } = require("./memory"); + return { + project: p, + instructions: projects.readInstructions(id), + memory: (() => { try { return fs.readFileSync(require("./memory").memoryFile(projects.memoryDir(id)), "utf8"); } catch { return ""; } })(), + knowledge: listKnowledge(projects.knowledgeDir(id)), + schedule: scheduler ? scheduler.list(id) : [], + }; +}); +ipcMain.handle("project:update", (_e, { id, name, description }) => { + if (!projects) return null; + const p = projects.describe(id, { name, description }); + if (p && sessions) sessions.renameProject(id, p.name); // broadcast the rail + return p; +}); +ipcMain.handle("project:instructions", (_e, { id, content }) => { + if (projects) projects.writeInstructions(id, content); + return true; +}); +ipcMain.handle("schedule:add", (_e, { id, prompt, every }) => { + try { return scheduler ? scheduler.add(id, { prompt, every }) : { error: "starting" }; } + catch (e) { return { error: e.message }; } +}); +ipcMain.handle("schedule:remove", (_e, { id, taskId }) => { if (scheduler) scheduler.remove(id, taskId); return true; }); +function knowledgeDirFor(projectId) { + if (!projects) return null; + if (projectId && projects.byId(projectId)) return projects.knowledgeDir(projectId); + const s = sessions && sessions.sessions.get(sessions.activeId); + return s ? projects.knowledgeDir(s.projectId) : null; +} +ipcMain.handle("knowledge:open", (_e, projectId) => { + const dir = knowledgeDirFor(projectId); + if (dir) shell.openPath(dir); + return Boolean(dir); +}); +ipcMain.handle("knowledge:import", async (_e, projectId) => { + const dir = knowledgeDirFor(projectId); + if (!dir) return { error: "no active project" }; + const r = await dialog.showOpenDialog(win, { properties: ["openFile", "multiSelections"], title: "Add files to project knowledge" }); + if (r.canceled || !r.filePaths.length) return { added: [] }; + const added = []; + for (const p of r.filePaths) { + try { fs.copyFileSync(p, path.join(dir, path.basename(p))); added.push(path.basename(p)); } catch {} + } + return { added }; +}); + // Open a project's memory file (or the global one) in the OS file manager/editor. ipcMain.handle("memory:open", (_e, scope) => { if (!projects) return false; diff --git a/electron/memory.js b/electron/memory.js index c350f47..539c7dd 100644 --- a/electron/memory.js +++ b/electron/memory.js @@ -29,6 +29,53 @@ const MEMORY_TOOL = { function memoryFile(dir) { return path.join(dir, "MEMORY.md"); } +// ── project knowledge ──────────────────────────────────────────────── +// Files the user adds to a project for the agent to consult. The prompt +// carries only the file list; bodies load through read_knowledge. + +const MAX_KNOWLEDGE_READ = 24_000; // chars per file read + +const KNOWLEDGE_TOOL = { + type: "function", + function: { + name: "read_knowledge", + description: "Read a file from this project's knowledge folder (documents the user uploaded as reference context). Call when a listed knowledge file is relevant to the task.", + parameters: { + type: "object", + properties: { name: { type: "string", description: "File name from the project-knowledge list." } }, + required: ["name"], + }, + }, +}; + +function listKnowledge(dir) { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isFile() && !e.name.startsWith(".")) + .map((e) => ({ name: e.name, size: fs.statSync(path.join(dir, e.name)).size })); + } catch { return []; } +} + +function knowledgeSection(dir) { + const files = dir ? listKnowledge(dir) : []; + if (!files.length) return ""; + const lines = files.map((f) => `- ${f.name} (${f.size > 1024 ? Math.round(f.size / 1024) + " KB" : f.size + " B"})`); + return `\n\n## Project knowledge (user-uploaded reference files)\nRead one with read_knowledge when relevant:\n${lines.join("\n")}`; +} + +function readKnowledge(dir, name) { + const base = path.basename(String(name || "")); // no path escapes + const file = path.join(dir, base); + if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { + const have = listKnowledge(dir).map((f) => f.name).join(", "); + return `No knowledge file named "${base}". Available: ${have || "none"}.`; + } + const buf = fs.readFileSync(file); + if (buf.includes(0)) return `"${base}" is a binary file (${buf.length} bytes) — cannot read as text.`; + const text = buf.toString("utf8"); + return text.length > MAX_KNOWLEDGE_READ ? text.slice(0, MAX_KNOWLEDGE_READ) + `\n…(truncated, ${text.length} chars total)` : text; +} + function saveMemory(dir, title, content) { fs.mkdirSync(dir, { recursive: true }); const file = memoryFile(dir); @@ -53,4 +100,4 @@ function loadForPrompt(globalDir, projectDir) { return out.trim(); } -module.exports = { MEMORY_TOOL, saveMemory, loadForPrompt, memoryFile }; +module.exports = { MEMORY_TOOL, saveMemory, loadForPrompt, memoryFile, KNOWLEDGE_TOOL, listKnowledge, knowledgeSection, readKnowledge }; diff --git a/electron/preload.js b/electron/preload.js index 2cca4ba..21062fa 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,7 +9,7 @@ contextBridge.exposeInMainWorld("omniwork", { listSessions: () => ipcRenderer.invoke("session:list"), setActiveSession: (id) => ipcRenderer.invoke("session:setActive", id), getTranscript: (id) => ipcRenderer.invoke("session:transcript", id), - sendMessage: (id, text, images) => ipcRenderer.invoke("session:send", { id, text, images }), + sendMessage: (id, text, images, label) => ipcRenderer.invoke("session:send", { id, text, images, label }), stopSession: (id) => ipcRenderer.invoke("session:stop", id), removeSession: (id) => ipcRenderer.invoke("session:remove", id), pickWorkspace: (id) => ipcRenderer.invoke("session:pickWorkspace", id), @@ -17,6 +17,15 @@ contextBridge.exposeInMainWorld("omniwork", { revealFolder: (p) => ipcRenderer.invoke("app:revealFolder", p), newProject: () => ipcRenderer.invoke("project:new"), openMemory: (scope) => ipcRenderer.invoke("memory:open", scope), + readMemory: (scope, projectId) => ipcRenderer.invoke("memory:read", { scope, projectId }), + writeMemory: (scope, content, projectId) => ipcRenderer.invoke("memory:write", { scope, content, projectId }), + projectInfo: (id) => ipcRenderer.invoke("project:info", id), + updateProject: (id, fields) => ipcRenderer.invoke("project:update", { id, ...fields }), + writeInstructions: (id, content) => ipcRenderer.invoke("project:instructions", { id, content }), + addSchedule: (id, prompt, every) => ipcRenderer.invoke("schedule:add", { id, prompt, every }), + removeSchedule: (id, taskId) => ipcRenderer.invoke("schedule:remove", { id, taskId }), + openKnowledge: (projectId) => ipcRenderer.invoke("knowledge:open", projectId), + importKnowledge: (projectId) => ipcRenderer.invoke("knowledge:import", projectId), listSkills: () => ipcRenderer.invoke("skills:list"), installSkills: (src) => ipcRenderer.invoke("skills:install", src), newSkill: (name) => ipcRenderer.invoke("skills:new", name), diff --git a/electron/projects.js b/electron/projects.js index 297da4c..56f001a 100644 --- a/electron/projects.js +++ b/electron/projects.js @@ -54,6 +54,30 @@ class ProjectManager { } byId(id) { return this.projects.get(id) || null; } + + describe(id, { name, description } = {}) { + const p = this.projects.get(id); + if (!p) return null; + if (name && String(name).trim()) p.name = String(name).trim().slice(0, 60); + if (description !== undefined) p.description = String(description).slice(0, 500); + this.save(); + return p; + } + + // Per-project instructions (like Claude's project instructions): injected + // into every session's system prompt, editable from the project page. + instructionsFile(id) { + fs.mkdirSync(path.join(this.rootDir, id), { recursive: true }); + return path.join(this.rootDir, id, "INSTRUCTIONS.md"); + } + + readInstructions(id) { + try { return fs.readFileSync(this.instructionsFile(id), "utf8"); } catch { return ""; } + } + + writeInstructions(id, content) { + fs.writeFileSync(this.instructionsFile(id), String(content ?? ""), "utf8"); + } list() { return [...this.projects.values()].sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0)); } touch(id) { const p = this.projects.get(id); if (p) { p.lastOpened = Date.now(); this.save(); } } @@ -70,6 +94,14 @@ class ProjectManager { return d; } + // Project knowledge: files the user drops in for the agent to consult + // (like Claude's project knowledge). Read on demand via read_knowledge. + knowledgeDir(id) { + const d = path.join(this.rootDir, id, "knowledge"); + fs.mkdirSync(d, { recursive: true }); + return d; + } + // One-time migration from the legacy single-file store (sessions.json). // Returns the saved sessions it found, already assigned to projects. migrateLegacy(legacyPath) { diff --git a/electron/scheduler.js b/electron/scheduler.js new file mode 100644 index 0000000..9c5d915 --- /dev/null +++ b/electron/scheduler.js @@ -0,0 +1,81 @@ +"use strict"; +// Scheduled tasks, like Claude's project "Scheduled" section: a recurring +// prompt per project that fires into a fresh session on an interval. +// Persisted per project (schedule.json); a one-minute tick checks due tasks. +// lastRun survives restarts, so a relaunch never replays missed history — +// a task fires when (now - lastRun) >= its interval. + +const fs = require("node:fs"); +const path = require("node:path"); + +const EVERY = { hour: 3_600_000, day: 86_400_000, week: 604_800_000 }; +const TICK_MS = 60_000; + +class Scheduler { + constructor({ projects, sessions }) { + this.projects = projects; + this.sessions = sessions; + this.timer = null; + } + + #file(pid) { return path.join(this.projects.rootDir, pid, "schedule.json"); } + + list(pid) { + try { return JSON.parse(fs.readFileSync(this.#file(pid), "utf8")); } catch { return []; } + } + + #save(pid, tasks) { + fs.mkdirSync(path.dirname(this.#file(pid)), { recursive: true }); + fs.writeFileSync(this.#file(pid), JSON.stringify(tasks, null, 2)); + } + + add(pid, { prompt, every }) { + const p = String(prompt || "").trim(); + if (!p) throw new Error("a prompt is required"); + if (!EVERY[every]) throw new Error("every must be hour, day, or week"); + const tasks = this.list(pid); + const task = { id: "t" + Date.now().toString(36), prompt: p.slice(0, 2000), every, lastRun: Date.now() }; + tasks.push(task); + this.#save(pid, tasks); + return task; + } + + remove(pid, taskId) { + this.#save(pid, this.list(pid).filter((t) => t.id !== taskId)); + } + + start() { + if (this.timer) return; + this.timer = setInterval(() => { try { this.tick(); } catch {} }, TICK_MS); + if (this.timer.unref) this.timer.unref(); + } + + stop() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } + + // Exposed for tests: fire everything due as of `now`. + tick(now = Date.now()) { + const fired = []; + for (const proj of this.projects.list()) { + const tasks = this.list(proj.id); + let dirty = false; + for (const t of tasks) { + if (now - (t.lastRun || 0) < (EVERY[t.every] || Infinity)) continue; + t.lastRun = now; + dirty = true; + fired.push({ project: proj, task: t }); + } + if (dirty) this.#save(proj.id, tasks); + } + for (const { project, task } of fired) this.#fire(project, task); + return fired.length; + } + + #fire(project, task) { + try { + const s = this.sessions.create({ workspace: project.path, title: "⏰ " + task.prompt.slice(0, 40) }); + this.sessions.send(s.id, task.prompt); + } catch {} + } +} + +module.exports = { Scheduler, EVERY }; diff --git a/electron/sessions.js b/electron/sessions.js index 7eb5cd9..6ee619d 100644 --- a/electron/sessions.js +++ b/electron/sessions.js @@ -117,13 +117,16 @@ class SessionManager { model: this.model, workspace: sess.workspace, mcp: this.mcp, - memory: this.projects ? { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(sess.projectId) } : null, + memory: this.projects ? { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(sess.projectId), knowledgeDir: this.projects.knowledgeDir(sess.projectId), instructionsFile: this.projects.instructionsFile(sess.projectId) } : null, skillsDir: this.skillsDir, browser: this.browser, approvalMode: this.approvalMode, approver: (callId, name, args, preview) => new Promise((resolve) => { this.pendingApprovals.set(callId, resolve); + // Remember what we're waiting on: switching sessions destroys the + // renderer's approval card, and the turn hangs forever without it. + sess.pendingApproval = { callId, name, args, preview }; this.emit(id, "approval_request", { callId, name, args, preview }); }), emit: (type, payload) => this.#onAgentEvent(id, type, payload), @@ -134,6 +137,9 @@ class SessionManager { resolveApproval(callId, ok) { const r = this.pendingApprovals.get(callId); if (r) { this.pendingApprovals.delete(callId); r(!!ok); } + for (const s of this.sessions.values()) { + if (s.pendingApproval && s.pendingApproval.callId === callId) s.pendingApproval = null; + } } setApprovalMode(mode) { @@ -231,14 +237,16 @@ class SessionManager { } catch {} } - async send(id, text, images) { + // `label` is what the transcript shows (e.g. prompt + attachment names); + // `text` is the full model input, which may embed attached file contents. + async send(id, text, images, label) { const sess = this.sessions.get(id); if (!sess) return; sess.status = "running"; this.#pushList(); - const label = text + (images && images.length ? ` 📎 ${images.length} image${images.length > 1 ? "s" : ""}` : ""); - sess.transcript.push({ type: "user", content: label }); - this.emit(id, "user", { content: label }); + const shown = (label || text) + (images && images.length ? ` 📎 ${images.length} image${images.length > 1 ? "s" : ""}` : ""); + sess.transcript.push({ type: "user", content: shown }); + this.emit(id, "user", { content: shown }); try { await sess.agent.send(text, images); } catch (e) { @@ -248,7 +256,14 @@ class SessionManager { } } - stop(id) { const s = this.sessions.get(id); if (s && s.agent) s.agent.abort(); } + stop(id) { + const s = this.sessions.get(id); + if (!s) return; + // A turn parked on an approval prompt isn't in the agent loop — deny it so + // the abort flag is actually reached. + if (s.pendingApproval) this.resolveApproval(s.pendingApproval.callId, false); + if (s.agent) s.agent.abort(); + } // Manual /compact. No-op while the agent is mid-turn — the step loop compacts itself. async compactNow(id) { @@ -299,7 +314,7 @@ class SessionManager { if (proj.id !== s.projectId) { try { fs.unlinkSync(this.#sessionFile(s)); } catch {} s.projectId = proj.id; - if (s.agent) s.agent.memory = { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(proj.id) }; + if (s.agent) s.agent.memory = { globalDir: this.globalMemoryDir, projectDir: this.projects.memoryDir(proj.id), knowledgeDir: this.projects.knowledgeDir(proj.id), instructionsFile: this.projects.instructionsFile(proj.id) }; } this.projects.touch(proj.id); } diff --git a/electron/sidecar.js b/electron/sidecar.js index e5807e8..bd993aa 100644 --- a/electron/sidecar.js +++ b/electron/sidecar.js @@ -104,6 +104,7 @@ class Gateway { this.ready = true; this.adopted = true; // not ours to kill on shutdown this.status("ready", "OmniRoute · free models"); + this.#watchdog(); // adopted gateways can die under us (their owner exits) return this.baseUrl; } @@ -156,6 +157,7 @@ class Gateway { this.#spawnServer(serverEntry, omniDir, env); try { await this.#waitHealthy(); + this.#watchdog(); return this.baseUrl; } catch (e) { this.stop(); @@ -189,6 +191,24 @@ class Gateway { this.proc.on("error", (e) => this.status("error", e.message)); } + // A gateway can vanish after we're "ready": an adopted one dies with its + // owner, or our own child crashes. Probe every 20s; if it's gone, take + // ownership and boot a fresh one on the same port — agents recover + // transparently since the base URL never changes. + #watchdog() { + clearInterval(this._watch); + this._watch = setInterval(async () => { + if (this._restarting || (await this.#adoptRunning())) return; + this._restarting = true; + this.ready = false; + this.adopted = false; + this.status("boot", "Engine went away — restarting…"); + try { await this.start(); } catch {} + this._restarting = false; + }, 20_000); + if (this._watch.unref) this._watch.unref(); + } + #log(d) { const line = d.toString().trim(); if (line && process.env.OMNIWORK_DEV) console.log("[omniroute]", line); @@ -221,6 +241,7 @@ class Gateway { } stop() { + clearInterval(this._watch); const proc = this.proc; this.proc = null; this.ready = false; diff --git a/package.json b/package.json index 98b6499..7cf5cad 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "test:browser": "node test/browser.js", "test:modes": "node test/modes.js", "test:mcp": "node test/mcp.js", + "test:scheduler": "node test/scheduler.js", + "test:approvals": "node test/approvals.js", "mcp": "node electron/mcp-server.js" }, "bin": { diff --git a/packaging/homebrew-tap/Casks/omniwork.rb b/packaging/homebrew-tap/Casks/omniwork.rb new file mode 100644 index 0000000..c8fda38 --- /dev/null +++ b/packaging/homebrew-tap/Casks/omniwork.rb @@ -0,0 +1,33 @@ +cask "omniwork" do + version "0.10.0" + sha256 "20671ad1a3f03fe74591680b57e7304d1d87e1213ff1e0ab6510b9e83a26e0cb" + + url "https://github.com/VedSoni-dev/omniwork/releases/download/v#{version}/OmniWork-#{version}-arm64.dmg" + name "OmniWork" + desc "Free, local, Claude Code-style desktop coding agent" + homepage "https://github.com/VedSoni-dev/omniwork" + + livecheck do + url :url + strategy :github_latest + end + + depends_on arch: :arm64 + depends_on macos: :monterey + + app "OmniWork.app" + + zap trash: [ + "~/Library/Application Support/omniwork", + "~/Library/Preferences/com.omniwork.app.plist", + "~/Library/Saved Application State/com.omniwork.app.savedState", + ] + + caveats <<~EOS + OmniWork is not signed with an Apple Developer certificate yet, so macOS + will block the first launch unless you either: + + installed with: brew install --cask --no-quarantine VedSoni-dev/tap/omniwork + or, once: right-click OmniWork.app in /Applications → Open + EOS +end diff --git a/packaging/homebrew-tap/README.md b/packaging/homebrew-tap/README.md new file mode 100644 index 0000000..b2673e2 --- /dev/null +++ b/packaging/homebrew-tap/README.md @@ -0,0 +1,34 @@ +# OmniWork Homebrew tap + +Install [OmniWork](https://github.com/VedSoni-dev/omniwork) — the free, local, +Claude Code-style coding agent: + +```sh +brew install --cask --no-quarantine VedSoni-dev/tap/omniwork +``` + +`--no-quarantine` skips the Gatekeeper block on the (unsigned) app. Without it, +right-click OmniWork.app → Open once after installing. + +--- + +## Maintaining (in the omniwork repo) + +This directory is the canonical source; the published tap lives at +`VedSoni-dev/homebrew-tap` and mirrors it. On each release: + +```sh +# 1. compute the new artifact's checksum +curl -sL "https://github.com/VedSoni-dev/omniwork/releases/download/v/OmniWork--arm64.dmg" | shasum -a 256 + +# 2. bump `version` and `sha256` in Casks/omniwork.rb (here), commit + +# 3. mirror to the tap repo +git clone git@github.com:VedSoni-dev/homebrew-tap.git /tmp/tap +cp -R packaging/homebrew-tap/* /tmp/tap/ +cd /tmp/tap && git add -A && git commit -m "omniwork " && git push +``` + +Publishing for the first time: create a **public repo named exactly +`homebrew-tap`** under the org/user, then run step 3. Nothing else — Homebrew +resolves `VedSoni-dev/tap` to that repo automatically. diff --git a/renderer/app.js b/renderer/app.js index f593d6c..8ab948b 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3,6 +3,8 @@ const stub = { createSession: async () => ({ id: "demo", title: "Main", workspace: "", status: "idle" }), newProject: async () => null, openMemory: async () => {}, compactSession: async () => {}, listSkills: async () => [], installSkills: async () => ({ error: "unavailable" }), newSkill: async () => ({}), openSkills: async () => {}, + readMemory: async () => ({ content: "" }), writeMemory: async () => {}, openKnowledge: async () => {}, importKnowledge: async () => ({ added: [] }), + projectInfo: async () => null, updateProject: async () => null, writeInstructions: async () => {}, addSchedule: async () => ({}), removeSchedule: async () => {}, listSessions: async () => ({ sessions: [], activeId: null }), setActiveSession: async () => {}, getTranscript: async () => [], sendMessage: async () => {}, stopSession: async () => {}, removeSession: async () => {}, pickWorkspace: async () => {}, @@ -31,6 +33,7 @@ let approvalMode = "auto"; let currentMcp = []; const mentions = new Set(); const pastedImages = []; // data URLs +const attachedFiles = []; // { name, content } — text files attached as context const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); const scrollDown = () => (scroll.scrollTop = scroll.scrollHeight); @@ -248,6 +251,7 @@ function handleSubagent(ev) { let switchSeq = 0; async function switchTo(id) { const seq = ++switchSeq; + viewingProject = null; activeId = id; await api.setActiveSession(id); if (seq !== switchSeq) return; @@ -255,9 +259,14 @@ async function switchTo(id) { streamEl = null; streamText = ""; pendingApproval = null; const t = await api.getTranscript(id); if (seq !== switchSeq) return; + const events = Array.isArray(t) ? t : (t && t.events) || []; scroll.innerHTML = ""; - if (!t || !t.length) welcome(); - else for (const ev of t) renderEvent(ev, false); + if (!events.length) welcome(); + else for (const ev of events) renderEvent(ev, false); + // A turn may be parked on an approval prompt — re-render its card, or the + // agent waits forever with no way to answer. + const pa = !Array.isArray(t) && t && t.pendingApproval; + if (pa) addApproval(pa.callId, pa.name, pa.args, pa.preview); $("ctx-meter").classList.add("hidden"); // repopulates from the session's context events updateFolder(); syncComposer(); scrollDown(); input.focus(); @@ -282,6 +291,9 @@ function folderMenu() { const items = [ { label: "Change folder…", hint: "pick any folder", onPick: () => api.pickWorkspace(activeId) }, { label: api.platform === "darwin" ? "Reveal in Finder" : "Show in file manager", hint: prettyPath(s.workspace), onPick: () => api.revealFolder(s.workspace) }, + { label: "🧠 Project memory…", hint: "view & edit what the agent remembers", onPick: () => openMemoryEditor() }, + { label: "📚 Add files to knowledge…", hint: "reference docs for every session here", onPick: async () => { const r = await api.importKnowledge(); if (r.added && r.added.length) addSystem(`📚 Added to project knowledge: ${r.added.join(", ")}`); } }, + { label: "📂 Open knowledge folder", hint: "drop files in directly", onPick: () => api.openKnowledge() }, ]; const seen = new Set([s.workspace]); for (const o of sessionsCache) { @@ -337,7 +349,9 @@ function renderSessions(list, act) { const s = await api.createSession({ workspace: g.workspace }).catch(() => null); if (s) { fileIndex = null; await switchTo(s.id); } else engineNotReady(); }); - head.addEventListener("click", () => { closed ? collapsedProjects.delete(pid) : collapsedProjects.add(pid); renderSessions(sessionsCache, activeId); }); + // Caret collapses; the name opens the project page. + head.querySelector(".pcaret").addEventListener("click", (e) => { e.stopPropagation(); closed ? collapsedProjects.delete(pid) : collapsedProjects.add(pid); renderSessions(sessionsCache, activeId); }); + head.addEventListener("click", () => openProjectView(pid)); head.querySelector(".pname").addEventListener("dblclick", (e) => { e.stopPropagation(); inlineRename(e.currentTarget, g.name, (v) => api.renameProject(pid, v)); @@ -399,7 +413,38 @@ function renderChips() { const box = $("chips"); box.innerHTML = ""; mentions.forEach((p) => { const c = document.createElement("span"); c.className = "chip"; c.innerHTML = `@${esc(p)} ✕`; c.querySelector(".x").addEventListener("click", () => { mentions.delete(p); renderChips(); }); box.appendChild(c); }); pastedImages.forEach((url, i) => { const c = document.createElement("span"); c.className = "chip imgchip"; c.innerHTML = ` image ✕`; c.querySelector(".x").addEventListener("click", () => { pastedImages.splice(i, 1); renderChips(); }); box.appendChild(c); }); + attachedFiles.forEach((f, i) => { const c = document.createElement("span"); c.className = "chip"; c.innerHTML = `📄 ${esc(f.name)} ✕`; c.querySelector(".x").addEventListener("click", () => { attachedFiles.splice(i, 1); renderChips(); }); box.appendChild(c); }); } + +// ── attachments: 📎 button + drag & drop ─────────────────────────── +// Images become vision input; text files ride along as context blocks. +const MAX_ATTACH = 8, MAX_FILE_BYTES = 400_000, MAX_FILE_CHARS = 100_000; +function addAttachment(file) { + if (pastedImages.length + attachedFiles.length >= MAX_ATTACH) { addSystem(`Attachment limit is ${MAX_ATTACH} per message.`); return; } + if (file.type && file.type.startsWith("image/")) { + const r = new FileReader(); + r.onload = () => { pastedImages.push(r.result); renderChips(); }; + r.readAsDataURL(file); + return; + } + if (file.size > MAX_FILE_BYTES) { addSystem(`"${file.name}" is too large to attach (max ${Math.round(MAX_FILE_BYTES / 1000)} KB). Add it to project knowledge instead (folder menu).`); return; } + const r = new FileReader(); + r.onload = () => { + const text = String(r.result || ""); + if (text.includes("\u0000")) { addSystem(`"${file.name}" looks binary — attach images or text files.`); return; } + attachedFiles.push({ name: file.name, content: text.slice(0, MAX_FILE_CHARS) }); + renderChips(); + }; + r.readAsText(file); +} +function handleFiles(list) { for (const f of list || []) addAttachment(f); input.focus(); } +$("attach").addEventListener("click", () => $("file-input").click()); +$("file-input").addEventListener("change", (e) => { handleFiles(e.target.files); e.target.value = ""; }); +let dragDepth = 0; +window.addEventListener("dragover", (e) => e.preventDefault()); +window.addEventListener("dragenter", (e) => { e.preventDefault(); if (++dragDepth === 1 && e.dataTransfer?.types?.includes("Files")) document.body.classList.add("dropping"); }); +window.addEventListener("dragleave", () => { if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); } }); +window.addEventListener("drop", (e) => { e.preventDefault(); dragDepth = 0; document.body.classList.remove("dropping"); handleFiles(e.dataTransfer.files); }); // paste an image into the composer input.addEventListener("paste", (e) => { const items = (e.clipboardData && e.clipboardData.items) || []; @@ -481,20 +526,39 @@ input.addEventListener("input", () => { input.addEventListener("keydown", (e) => { if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); cycleApproval(); return; } if (pendingApproval && !input.value.trim() && (e.key === "y" || e.key === "n")) { e.preventDefault(); pendingApproval(e.key === "y"); return; } - if (mpop) { if (e.key === "ArrowDown") { e.preventDefault(); moveSel(1); return; } if (e.key === "ArrowUp") { e.preventDefault(); moveSel(-1); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); pickSel(); return; } if (e.key === "Escape") { hidePop(); return; } } + if (mpop) { if (e.key === "ArrowDown") { e.preventDefault(); moveSel(1); return; } if (e.key === "ArrowUp") { e.preventDefault(); moveSel(-1); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); pickSel(); return; } if (e.key === "Escape") { e.preventDefault(); hidePop(); return; } } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } - if (e.key === "Escape") api.stopSession(activeId); +}); +// Esc stops the running turn from anywhere — not only while the input has focus. +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape" || e.defaultPrevented) return; + if (!$("memory-modal").classList.contains("hidden")) { $("memory-modal").classList.add("hidden"); return; } + if (mpop) { hidePop(); return; } + if (activeId) api.stopSession(activeId); }); $("approval-toggle").addEventListener("click", cycleApproval); async function send() { const text = input.value.trim(); if ((!text && !mentions.size && !pastedImages.length) || !activeId) return; if (text.startsWith("/")) { input.value = ""; grow(); hidePop(); handleSlash(text); return; } + // On a project page, a message starts a new session in that project. + if (viewingProject) { + const s = await api.createSession({ workspace: viewingProjectPath }).catch(() => null); + if (!s) { engineNotReady(); return; } + fileIndex = null; + await switchTo(s.id); + } let full = text; if (mentions.size) full = `${text}\n\nReferenced files: ${[...mentions].map((p) => "@" + p).join(", ")}`; + // Attached text files ride along for the model; the transcript shows only a label. + let label = null; + if (attachedFiles.length) { + label = text + ` 📎 ${attachedFiles.map((f) => f.name).join(", ")}`; + full += "\n\n" + attachedFiles.map((f) => `## Attached file: ${f.name}\n\`\`\`\n${f.content}\n\`\`\``).join("\n\n"); + } const images = pastedImages.slice(); const w = $("welcome"); if (w) w.remove(); - input.value = ""; mentions.clear(); pastedImages.length = 0; renderChips(); grow(); hidePop(); - await api.sendMessage(activeId, full, images); + input.value = ""; mentions.clear(); pastedImages.length = 0; attachedFiles.length = 0; renderChips(); grow(); hidePop(); + await api.sendMessage(activeId, full, images, label); } // ── slash commands ───────────────────────────────────────────────── @@ -516,7 +580,8 @@ const COMMANDS = [ { name: "clear", desc: "Start a fresh session", run: () => newSession() }, { name: "new", desc: "Start a fresh session", run: () => newSession() }, { name: "project", desc: "New project — pick a folder", run: () => newProject() }, - { name: "memory", desc: "Open this project's memory file", run: (arg) => api.openMemory(arg === "global" ? "global" : undefined) }, + { name: "memory", desc: "View & edit what the agent remembers", run: () => openMemoryEditor() }, + { name: "knowledge", desc: "Open this project's knowledge folder", run: () => api.openKnowledge() }, { name: "folder", alias: "cwd", desc: "Change the working folder", run: () => api.pickWorkspace(activeId) }, { name: "model", args: "", desc: "Switch AI model", run: (arg) => { if (arg) { $("model").value = arg; api.setModel(arg); addSystem("model → " + arg); } else addSystem("usage: /model "); } }, { name: "undo", desc: "Undo last turn's file changes", run: () => api.undo(activeId) }, @@ -577,6 +642,81 @@ function cycleApproval() { stopBtn.addEventListener("click", () => api.stopSession(activeId)); document.addEventListener("click", (e) => { const t = e.target.closest(".tip"); if (t) { input.value = t.dataset.p; grow(); input.focus(); } }); +// ── memory editor ────────────────────────────────────────────────── +let memScope = "project"; +let memProjectId = null; // explicit project, else the active session's +async function loadMemoryEditor(scope) { + memScope = scope; + $("mem-tab-project").classList.toggle("on", scope === "project"); + $("mem-tab-global").classList.toggle("on", scope === "global"); + const r = await api.readMemory(scope, scope === "project" ? memProjectId : undefined); + $("mem-text").value = r.content || ""; +} +function openMemoryEditor(projectId) { memProjectId = projectId || null; $("memory-modal").classList.remove("hidden"); loadMemoryEditor("project"); } +async function saveMemoryEditor() { await api.writeMemory(memScope, $("mem-text").value, memScope === "project" ? memProjectId : undefined); } +$("mem-tab-project").addEventListener("click", async () => { await saveMemoryEditor(); loadMemoryEditor("project"); }); +$("mem-tab-global").addEventListener("click", async () => { await saveMemoryEditor(); loadMemoryEditor("global"); }); +$("mem-save").addEventListener("click", async () => { await saveMemoryEditor(); $("memory-modal").classList.add("hidden"); addSystem("🧠 Memory saved."); }); +$("mem-cancel").addEventListener("click", () => $("memory-modal").classList.add("hidden")); +$("mem-reveal").addEventListener("click", () => api.openMemory(memScope === "global" ? "global" : undefined)); +$("memory-modal").addEventListener("click", (e) => { if (e.target.id === "memory-modal") $("memory-modal").classList.add("hidden"); }); + +// ── project page (like Claude's project view) ────────────────────── +let viewingProject = null, viewingProjectPath = null; +const fmtDate = (ts) => new Date(ts || Date.now()).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +function pvSection(title, actionsHtml, bodyHtml) { + return `

${title}

${actionsHtml || ""}
${bodyHtml}
`; +} +async function openProjectView(pid) { + const info = await api.projectInfo(pid); + if (!info) return; + viewingProject = pid; + viewingProjectPath = info.project.path; + switchSeq++; // cancel any in-flight transcript render + stopThinking(); endStream(); + $("ctx-meter").classList.add("hidden"); + const p = info.project; + const mySessions = sessionsCache.filter((s) => s.projectId === pid); + scroll.innerHTML = ""; + const el = document.createElement("div"); + el.className = "pview"; + el.innerHTML = + `

${esc(p.name)}

${esc(prettyPath(p.path))}
` + + `` + + pvSection("Instructions", "", + ``) + + pvSection("Memory", ``, + `

${info.memory ? esc(info.memory.replace(/^# Memory\s*/, "").slice(0, 280)) + (info.memory.length > 280 ? "…" : "") : "Nothing saved yet — the agent adds entries with save_memory, or write your own."}

`) + + pvSection("Context", ``, + info.knowledge.length + ? `
    ${info.knowledge.map((f) => `
  • 📄 ${esc(f.name)} ${f.size > 1024 ? Math.round(f.size / 1024) + " KB" : f.size + " B"}
  • `).join("")}
` + : `

Add documents for the agent to reference in every session of this project.

`) + + pvSection("Scheduled", "", + `
    ${info.schedule.map((t) => `
  • ⏰ ${esc(t.prompt.slice(0, 60))} every ${esc(t.every)} ✕
  • `).join("")}
` + + `
`) + + pvSection("Recents", "", + mySessions.length + ? `
    ${mySessions.map((s) => `
  • ${esc(s.title)} ${fmtDate(s.createdAt)}
  • `).join("")}
` + : `

Sessions you start here will show up below. Type a message to begin one.

`); + scroll.appendChild(el); + + // wiring + el.querySelector(".pv-title").addEventListener("dblclick", (e) => inlineRename(e.currentTarget, p.name, async (v) => { await api.updateProject(pid, { name: v }); openProjectView(pid); })); + el.querySelector(".pv-desc").addEventListener("blur", (e) => api.updateProject(pid, { description: e.target.value })); + el.querySelector("#pv-instructions").addEventListener("blur", (e) => api.writeInstructions(pid, e.target.value)); + el.querySelector("#pv-mem-edit").addEventListener("click", () => openMemoryEditor(pid)); + el.querySelector("#pv-know-add").addEventListener("click", async () => { const r = await api.importKnowledge(pid); if (r.added && r.added.length) openProjectView(pid); }); + el.querySelector("#pv-know-open").addEventListener("click", () => api.openKnowledge(pid)); + el.querySelector("#pv-sched-btn").addEventListener("click", async () => { + const r = await api.addSchedule(pid, $("pv-sched-prompt").value, $("pv-sched-every").value); + if (r && r.error) addSystem("✗ " + r.error); else openProjectView(pid); + }); + el.querySelectorAll("#pv-sched-list .pv-x").forEach((x) => x.addEventListener("click", async (e) => { await api.removeSchedule(pid, e.target.closest("li").dataset.tid); openProjectView(pid); })); + el.querySelectorAll(".pv-recents li").forEach((li) => li.addEventListener("click", () => switchTo(li.dataset.sid))); + scroll.scrollTop = 0; + input.focus(); +} + // ── new session / cwd / model / dashboard ────────────────────────── $("new-project").addEventListener("click", () => newProject()); $("folder-chip").addEventListener("click", folderMenu); diff --git a/renderer/index.html b/renderer/index.html index a107e17..bd4b99b 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -38,8 +38,10 @@
> +
+
starting… @@ -89,6 +91,24 @@
+ + +