commit 2ec18964b7bca5606476d0203722222aee484287 Author: MrSphay Date: Fri Jun 19 01:07:16 2026 +0200 Initial Coworker app scaffold diff --git a/.codex/project.md b/.codex/project.md new file mode 100644 index 0000000..7ba5f14 --- /dev/null +++ b/.codex/project.md @@ -0,0 +1,75 @@ +# Codex Project Notes + +## Project + +`Coworker` is a local-first Windows AI coding coworker. It combines a Codex-style desktop app, local model provider configuration, a persistent Brain for memory and skills, and optional iPhone/iPad PWA access. + +Repository: + +```text +Code-Inc/Coworker +``` + +## Stack + +```text +Electron, React, TypeScript, Vite +``` + +Package manager: + +```text +npm +``` + +## Commands + +Run only on Gitea Ubuntu runners: + +```text +INSTALL_COMMAND = npm install +LINT_COMMAND = npm run lint +TEST_COMMAND = npm run test +BUILD_COMMAND = npm run build +AUDIT_COMMAND = npm run audit +PACKAGE_COMMAND = npm run package:win +``` + +Local checks are limited to lightweight validation such as `git diff --check`. + +## Build Artifacts + +Release artifacts are produced in: + +```text +release/ +``` + +Expected files: + +```text +Coworker-Setup-.exe +SHA256SUMS.txt +``` + +## Security Rules + +- Web access is disabled by default. +- LAN/PWA access requires pairing-code authentication and scoped device tokens. +- Workspace tools may access only explicitly selected project directories. +- Agent writes and command execution require visible user approval. +- Local provider endpoints and tokens are stored in user app data, not tracked files. +- Unsigned installers are acceptable until a code-signing certificate is available. + +## Release Rules + +Before a release: + +1. verify Gitea CI is green, +2. run release dry-run workflow, +3. confirm `Coworker-Setup-.exe` and `SHA256SUMS.txt` exist, +4. update `CHANGELOG.md` and release notes, +5. create a `v*` tag, +6. let Gitea Actions publish the release artifact. + +Do not create a release unless explicitly requested. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..e6cfef9 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,47 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test + + - name: Build + run: npm run build + + - name: Build Windows installer + run: | + docker run --rm \ + -v "$PWD":/project \ + -w /project \ + electronuserland/builder:wine \ + /bin/bash -lc "npm run package:win" + + - name: Checksums + run: | + cd release + sha256sum Coworker-Setup-*.exe > SHA256SUMS.txt + + - name: Upload installer artifact + uses: actions/upload-artifact@v3 + with: + name: coworker-windows-installer + path: | + release/Coworker-Setup-*.exe + release/SHA256SUMS.txt diff --git a/.gitea/workflows/dependency-check.yml b/.gitea/workflows/dependency-check.yml new file mode 100644 index 0000000..c44db0a --- /dev/null +++ b/.gitea/workflows/dependency-check.yml @@ -0,0 +1,22 @@ +name: Scheduled Dependency Check + +on: + schedule: + - cron: "29 3 * * 2" + workflow_dispatch: + +jobs: + dependency-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Audit + run: npm run audit + + - name: Outdated report + run: npm outdated || true diff --git a/.gitea/workflows/release-dry-run.yml b/.gitea/workflows/release-dry-run.yml new file mode 100644 index 0000000..a794e9d --- /dev/null +++ b/.gitea/workflows/release-dry-run.yml @@ -0,0 +1,31 @@ +name: Release Dry Run + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + release-dry-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate release metadata + run: | + test -f README.md + test -f CHANGELOG.md + test -f docs/release-checklist.md + test -f docs/release-notes.md + grep -q "Coworker-Setup" README.md + if rg "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND" .; then + echo "Unresolved template placeholder found" + exit 1 + fi + + - name: Install dependencies + run: npm install + + - name: Release check + run: npm run release:check diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ddb21a1 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Validate + run: npm run release:check + + - name: Build Windows installer + run: | + docker run --rm \ + -v "$PWD":/project \ + -w /project \ + electronuserland/builder:wine \ + /bin/bash -lc "npm run package:win" + + - name: Checksums + run: | + cd release + sha256sum Coworker-Setup-*.exe > SHA256SUMS.txt + + - name: Create Gitea release and upload assets + run: | + test -n "$GITEA_TOKEN" + server="${GITHUB_SERVER_URL:-https://git.wilkensxl.de}" + repo="${GITHUB_REPOSITORY:-Code-Inc/Coworker}" + tag="${GITHUB_REF_NAME:-manual}" + body="$(python3 - <<'PY' +from pathlib import Path +print(Path("docs/release-notes.md").read_text(encoding="utf-8")) +PY +)" + release_json="$(python3 - <.exe +release/SHA256SUMS.txt +``` + +The installer is unsigned until a Windows code-signing certificate is configured. The app can check Gitea releases for updates, but silent auto-update is intentionally deferred until signing exists. + +## Web/PWA Access + +Web access is disabled by default. Enable it in Settings, choose localhost or LAN/Tailscale binding, then create a pairing code. Open the displayed URL on iPhone or iPad and add Coworker to the Home Screen. + +Security defaults: + +- pairing is required, +- device tokens are stored hashed, +- paired devices can be revoked in a future settings panel, +- workspace access stays inside selected project folders. + +## Architecture + +```text +apps/desktop Electron main process, preload bridge, React renderer +packages/core Agent, Brain, provider, pairing, and shared types +docs release, security, and project handoff documents +.gitea/workflows Gitea CI, installer, security, and release automation +``` + +## Release + +1. Ensure the Gitea build workflow succeeds. +2. Update `CHANGELOG.md`. +3. Create a `v*` tag. +4. Let the release workflow upload the installer and checksum to Gitea. + +Do not create a release from a local build. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8da0fa9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,29 @@ +# Security + +Coworker is a local-first coding app with powerful project and agent capabilities. Treat it like a developer tool with access to your files and local model endpoints. + +## Reporting + +Open a private issue or contact the repository owner if a vulnerability exposes files, tokens, local services, paired devices, or agent permissions. + +## Defaults + +- Web access is disabled by default. +- LAN/Tailscale access requires an explicit settings change. +- Mobile access requires a pairing code. +- Device tokens are stored as hashes. +- Workspace file access is confined to selected project roots. +- Agent file writes and command execution require visible approval. + +## Secrets + +Never commit: + +- `.env` files, +- API keys, +- provider tokens, +- pairing tokens, +- certificates, +- private keys, +- app data, +- generated installers outside release artifacts. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts new file mode 100644 index 0000000..44a95ed --- /dev/null +++ b/apps/desktop/electron/main.ts @@ -0,0 +1,416 @@ +import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; +import { createHash, randomBytes } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { createServer, IncomingMessage, Server, ServerResponse } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + AppState, + BrainInput, + ModelProvider, + WebAccessSettings, + createBrainItem, + createPairedDevice, + createPairingChallenge, + createProvider, + isPairingChallengeValid, + nowIso, + probeOpenAiCompatibleProvider, + runAgent, + updateBrainItem +} from "@coworker/core"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rendererDir = path.resolve(__dirname, "../dist"); +const stateFile = () => path.join(app.getPath("userData"), "state.json"); + +let mainWindow: BrowserWindow | undefined; +let webServer: Server | undefined; +let activePairing = createPairingChallenge(0); + +const defaultState: AppState = { + providers: [ + createProvider("ollama", "Ollama", "http://localhost:11434/v1"), + createProvider("lmstudio", "LM Studio", "http://localhost:1234/v1") + ], + brain: [], + workspaces: [], + webAccess: { + enabled: false, + bindHost: "127.0.0.1", + port: 4789, + requirePairing: true + }, + pairedDevices: [] +}; + +async function readState(): Promise { + try { + return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) }; + } catch { + return defaultState; + } +} + +async function writeState(state: AppState): Promise { + await mkdir(path.dirname(stateFile()), { recursive: true }); + await writeFile(stateFile(), JSON.stringify(state, null, 2), "utf8"); + return state; +} + +async function mutateState(mutator: (state: AppState) => AppState | Promise): Promise { + const current = await readState(); + const next = await mutator(current); + return writeState(next); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1320, + height: 860, + minWidth: 980, + minHeight: 680, + title: "Coworker", + backgroundColor: "#111317", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false + } + }); + + const devUrl = process.env.VITE_DEV_SERVER_URL; + if (devUrl) { + void mainWindow.loadURL(devUrl); + } else { + void mainWindow.loadFile(path.join(rendererDir, "index.html")); + } + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); +} + +function registerIpc() { + ipcMain.handle("state:get", () => readState()); + + ipcMain.handle("providers:save", async (_event, provider: ModelProvider) => mutateState((state) => { + const next = { ...provider, baseUrl: provider.baseUrl.replace(/\/+$/, ""), updatedAt: nowIso() }; + return { + ...state, + providers: state.providers.some((item) => item.id === next.id) + ? state.providers.map((item) => item.id === next.id ? next : item) + : [...state.providers, next] + }; + })); + + ipcMain.handle("providers:remove", async (_event, id: string) => mutateState((state) => ({ + ...state, + providers: state.providers.filter((provider) => provider.id !== id) + }))); + + ipcMain.handle("providers:probe", async (_event, id: string) => { + const state = await readState(); + const provider = state.providers.find((item) => item.id === id); + if (!provider) throw new Error("Provider not found."); + return probeOpenAiCompatibleProvider(provider); + }); + + ipcMain.handle("brain:add", async (_event, input: BrainInput) => mutateState((state) => ({ + ...state, + brain: [createBrainItem(input), ...state.brain] + }))); + + ipcMain.handle("brain:update", async (_event, id: string, patch: Partial) => mutateState((state) => ({ + ...state, + brain: state.brain.map((item) => item.id === id ? updateBrainItem(item, patch) : item) + }))); + + ipcMain.handle("brain:remove", async (_event, id: string) => mutateState((state) => ({ + ...state, + brain: state.brain.filter((item) => item.id !== id) + }))); + + ipcMain.handle("workspace:open", async () => { + const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); + if (result.canceled || !result.filePaths[0]) return undefined; + const rootPath = result.filePaths[0]; + const workspace = { + id: createHash("sha256").update(rootPath).digest("hex").slice(0, 16), + name: path.basename(rootPath), + rootPath, + openedAt: nowIso() + }; + await mutateState((state) => ({ + ...state, + workspaces: [workspace, ...state.workspaces.filter((item) => item.id !== workspace.id)] + })); + return workspace; + }); + + ipcMain.handle("workspace:list", async (_event, id: string) => { + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) throw new Error("Workspace not found."); + const entries = await readdir(workspace.rootPath, { withFileTypes: true }); + return Promise.all(entries.slice(0, 200).map(async (entry) => { + const absolute = path.join(workspace.rootPath, entry.name); + const info = await stat(absolute); + return { + path: entry.name, + type: entry.isDirectory() ? "directory" : "file", + size: entry.isFile() ? info.size : undefined, + modifiedAt: info.mtime.toISOString() + }; + })); + }); + + ipcMain.handle("workspace:read", async (_event, id: string, relativePath: string) => { + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) throw new Error("Workspace not found."); + const absolute = confinedPath(workspace.rootPath, relativePath); + const info = await stat(absolute); + if (!info.isFile() || info.size > 512 * 1024) throw new Error("File is not readable in preview."); + return readFile(absolute, "utf8"); + }); + + ipcMain.handle("agent:run", async (_event, request) => { + const state = await readState(); + const provider = request.providerId + ? state.providers.find((item) => item.id === request.providerId) + : state.providers.find((item) => item.enabled); + const model = request.model ?? provider?.defaults.coding ?? provider?.defaults.chat; + return runAgent( + { + prompt: String(request.prompt ?? ""), + workspace: request.workspace, + provider, + model, + messages: request.messages ?? [], + brain: state.brain + }, + (messages) => completeWithProvider(provider, model, messages) + ); + }); + + ipcMain.handle("web:settings", async (_event, settings: WebAccessSettings) => { + const state = await mutateState((current) => ({ ...current, webAccess: settings })); + await restartWebServer(state.webAccess); + return state; + }); + + ipcMain.handle("web:pairing:create", () => { + activePairing = createPairingChallenge(); + return activePairing; + }); +} + +async function completeWithProvider(provider: ModelProvider | undefined, model: string | undefined, messages: { role: string; content: string }[]) { + if (!provider || !model) { + return "No model is configured yet. Add Ollama, LM Studio, or another OpenAI-compatible provider in Settings."; + } + const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}) + }, + body: JSON.stringify({ + model, + messages: messages.map((message) => ({ role: message.role, content: message.content })), + temperature: 0.2 + }) + }); + if (!response.ok) return `Model request failed with HTTP ${response.status}.`; + const body = await response.json() as { choices?: Array<{ message?: { content?: string } }> }; + return body.choices?.[0]?.message?.content ?? "The model returned an empty response."; +} + +function confinedPath(root: string, relativePath: string) { + const absolute = path.resolve(root, relativePath); + const normalizedRoot = path.resolve(root); + if (absolute !== normalizedRoot && !absolute.startsWith(`${normalizedRoot}${path.sep}`)) { + throw new Error("Path is outside the workspace."); + } + return absolute; +} + +async function restartWebServer(settings: WebAccessSettings) { + if (webServer) { + await new Promise((resolve) => webServer?.close(() => resolve())); + webServer = undefined; + } + if (!settings.enabled) return; + webServer = createServer(handleWebRequest); + await new Promise((resolve, reject) => { + webServer?.listen(settings.port, settings.bindHost, resolve); + webServer?.on("error", reject); + }); +} + +async function handleWebRequest(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url ?? "/", "http://coworker.local"); + if (url.pathname === "/api/pair" && req.method === "POST") return handlePair(req, res); + if (url.pathname === "/api/state") return sendJson(res, await publicState(req)); + if (url.pathname.startsWith("/api/") && !(await isAuthorized(req))) return sendJson(res, { error: "Pairing required." }, 401); + if (url.pathname === "/api/providers" && req.method === "POST") return handleWebProviderSave(req, res); + if (url.pathname.match(/^\/api\/providers\/[^/]+\/probe$/) && req.method === "POST") return handleWebProviderProbe(url, res); + if (url.pathname === "/api/brain" && req.method === "POST") return handleWebBrainAdd(req, res); + if (url.pathname.match(/^\/api\/brain\/[^/]+$/) && req.method === "DELETE") return handleWebBrainRemove(url, res); + if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files$/)) return handleWebWorkspaceList(url, res); + if (url.pathname.match(/^\/api\/workspaces\/[^/]+\/files\/.+$/)) return handleWebWorkspaceRead(url, res); + if (url.pathname === "/api/agent/run" && req.method === "POST") return handleWebAgent(req, res); + return serveStatic(url.pathname, res); +} + +async function handlePair(req: IncomingMessage, res: ServerResponse) { + const body = await readJsonBody<{ code?: string; name?: string }>(req); + if (!isPairingChallengeValid(activePairing, body.code ?? "")) return sendJson(res, { error: "Invalid pairing code." }, 403); + const token = randomBytes(24).toString("hex"); + const device = await createPairedDevice(body.name ?? "Mobile device", token, digest); + await mutateState((state) => ({ ...state, pairedDevices: [device, ...state.pairedDevices] })); + res.setHeader("Set-Cookie", `coworker_token=${token}; HttpOnly; SameSite=Lax; Path=/`); + sendJson(res, { token }); +} + +async function handleWebProviderSave(req: IncomingMessage, res: ServerResponse) { + const provider = await readJsonBody(req); + const state = await mutateState((current) => ({ + ...current, + providers: current.providers.some((item) => item.id === provider.id) + ? current.providers.map((item) => item.id === provider.id ? { ...provider, updatedAt: nowIso() } : item) + : [...current.providers, { ...createProvider(provider.kind, provider.name, provider.baseUrl), ...provider, updatedAt: nowIso() }] + })); + sendJson(res, sanitizeState(state)); +} + +async function handleWebProviderProbe(url: URL, res: ServerResponse) { + const id = decodeURIComponent(url.pathname.split("/")[3]); + const provider = (await readState()).providers.find((item) => item.id === id); + if (!provider) return sendJson(res, { error: "Provider not found." }, 404); + sendJson(res, await probeOpenAiCompatibleProvider(provider)); +} + +async function handleWebBrainAdd(req: IncomingMessage, res: ServerResponse) { + const input = await readJsonBody(req); + const state = await mutateState((current) => ({ ...current, brain: [createBrainItem(input), ...current.brain] })); + sendJson(res, sanitizeState(state)); +} + +async function handleWebBrainRemove(url: URL, res: ServerResponse) { + const id = decodeURIComponent(url.pathname.split("/")[3]); + const state = await mutateState((current) => ({ ...current, brain: current.brain.filter((item) => item.id !== id) })); + sendJson(res, sanitizeState(state)); +} + +async function handleWebWorkspaceList(url: URL, res: ServerResponse) { + const id = decodeURIComponent(url.pathname.split("/")[3]); + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404); + const entries = await readdir(workspace.rootPath, { withFileTypes: true }); + const files = await Promise.all(entries.slice(0, 200).map(async (entry) => { + const absolute = path.join(workspace.rootPath, entry.name); + const info = await stat(absolute); + return { + path: entry.name, + type: entry.isDirectory() ? "directory" : "file", + size: entry.isFile() ? info.size : undefined, + modifiedAt: info.mtime.toISOString() + }; + })); + sendJson(res, files); +} + +async function handleWebWorkspaceRead(url: URL, res: ServerResponse) { + const parts = url.pathname.split("/"); + const id = decodeURIComponent(parts[3]); + const relativePath = decodeURIComponent(parts.slice(5).join("/")); + const workspace = (await readState()).workspaces.find((item) => item.id === id); + if (!workspace) return sendJson(res, { error: "Workspace not found." }, 404); + const absolute = confinedPath(workspace.rootPath, relativePath); + const info = await stat(absolute); + if (!info.isFile() || info.size > 512 * 1024) return sendJson(res, { error: "File is not readable in preview." }, 400); + sendJson(res, { content: await readFile(absolute, "utf8") }); +} + +async function handleWebAgent(req: IncomingMessage, res: ServerResponse) { + const body = await readJsonBody<{ prompt?: string; messages?: unknown[]; providerId?: string; model?: string }>(req); + const state = await readState(); + const provider = body.providerId ? state.providers.find((item) => item.id === body.providerId) : state.providers[0]; + const result = await runAgent( + { prompt: body.prompt ?? "", messages: [], brain: state.brain, provider, model: body.model }, + (messages) => completeWithProvider(provider, body.model ?? provider?.defaults.chat, messages) + ); + sendJson(res, result); +} + +async function publicState(req: IncomingMessage) { + const state = await readState(); + const paired = await isAuthorized(req); + return { + paired, + webAccess: state.webAccess, + providers: paired ? state.providers.map(({ apiKey, ...provider }) => provider) : [], + brain: paired ? state.brain : [], + workspaces: paired ? state.workspaces : [] + }; +} + +async function isAuthorized(req: IncomingMessage) { + const token = (req.headers.cookie ?? "").split(";").map((item) => item.trim()).find((item) => item.startsWith("coworker_token="))?.split("=")[1]; + if (!token) return false; + const hash = await digest(token); + const state = await readState(); + return state.pairedDevices.some((device) => !device.revokedAt && device.tokenHash === hash); +} + +async function digest(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + +function sanitizeState(state: AppState): AppState { + return { + ...state, + providers: state.providers.map(({ apiKey, ...provider }) => provider), + pairedDevices: state.pairedDevices.map((device) => ({ ...device, tokenHash: "" })) + }; +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as T; +} + +function sendJson(res: ServerResponse, data: unknown, status = 200) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function serveStatic(urlPath: string, res: ServerResponse) { + try { + const safePath = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, ""); + const absolute = confinedPath(rendererDir, safePath); + const finalPath = absolute.endsWith(path.sep) ? path.join(absolute, "index.html") : absolute; + createReadStream(finalPath) + .on("error", () => createReadStream(path.join(rendererDir, "index.html")).pipe(res)) + .pipe(res); + } catch { + res.writeHead(403); + res.end("Forbidden"); + } +} + +app.whenReady().then(async () => { + registerIpc(); + createWindow(); + await restartWebServer((await readState()).webAccess); + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts new file mode 100644 index 0000000..988ffad --- /dev/null +++ b/apps/desktop/electron/preload.ts @@ -0,0 +1,31 @@ +import { contextBridge, ipcRenderer } from "electron"; + +const api = { + state: () => ipcRenderer.invoke("state:get"), + providers: { + save: (provider: unknown) => ipcRenderer.invoke("providers:save", provider), + remove: (id: string) => ipcRenderer.invoke("providers:remove", id), + probe: (id: string) => ipcRenderer.invoke("providers:probe", id) + }, + brain: { + add: (input: unknown) => ipcRenderer.invoke("brain:add", input), + update: (id: string, patch: unknown) => ipcRenderer.invoke("brain:update", id, patch), + remove: (id: string) => ipcRenderer.invoke("brain:remove", id) + }, + workspace: { + open: () => ipcRenderer.invoke("workspace:open"), + list: (id: string) => ipcRenderer.invoke("workspace:list", id), + read: (id: string, relativePath: string) => ipcRenderer.invoke("workspace:read", id, relativePath) + }, + agent: { + run: (request: unknown) => ipcRenderer.invoke("agent:run", request) + }, + web: { + saveSettings: (settings: unknown) => ipcRenderer.invoke("web:settings", settings), + createPairing: () => ipcRenderer.invoke("web:pairing:create") + } +}; + +contextBridge.exposeInMainWorld("coworker", api); + +export type CoworkerApi = typeof api; diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..1bc2205 --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Coworker + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..9e00fb9 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,6 @@ +{ + "name": "@coworker/desktop", + "version": "0.1.0", + "type": "module", + "private": true +} diff --git a/apps/desktop/public/icon.svg b/apps/desktop/public/icon.svg new file mode 100644 index 0000000..bdec161 --- /dev/null +++ b/apps/desktop/public/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/desktop/public/manifest.webmanifest b/apps/desktop/public/manifest.webmanifest new file mode 100644 index 0000000..1feb38b --- /dev/null +++ b/apps/desktop/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Coworker", + "short_name": "Coworker", + "display": "standalone", + "background_color": "#111317", + "theme_color": "#111317", + "start_url": "/", + "scope": "/", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/apps/desktop/public/sw.js b/apps/desktop/public/sw.js new file mode 100644 index 0000000..2c51f8a --- /dev/null +++ b/apps/desktop/public/sw.js @@ -0,0 +1,13 @@ +self.addEventListener("install", (event) => { + event.waitUntil(caches.open("coworker-shell-v1").then((cache) => cache.addAll(["/", "/manifest.webmanifest", "/icon.svg"]))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((cached) => cached || caches.match("/")))); +}); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts new file mode 100644 index 0000000..a9f7ac5 --- /dev/null +++ b/apps/desktop/src/api.ts @@ -0,0 +1,86 @@ +import type { + AgentRunResult, + AppState, + BrainInput, + BrainItem, + ModelProvider, + PairingChallenge, + ProviderProbeResult, + WebAccessSettings, + WorkspaceFile, + WorkspaceState +} from "@coworker/core"; + +type AgentRequest = { + prompt: string; + providerId?: string; + model?: string; + workspace?: WorkspaceState; + messages?: Array<{ role: string; content: string }>; +}; + +async function json(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}) + } + }); + if (!response.ok) throw new Error(await response.text()); + return response.json() as Promise; +} + +export const coworkerApi = { + state(): Promise { + if (window.coworker) return window.coworker.state() as Promise; + return json("/api/state"); + }, + saveProvider(provider: ModelProvider): Promise { + if (window.coworker) return window.coworker.providers.save(provider) as Promise; + return json("/api/providers", { method: "POST", body: JSON.stringify(provider) }); + }, + probeProvider(id: string): Promise { + if (window.coworker) return window.coworker.providers.probe(id) as Promise; + return json(`/api/providers/${id}/probe`, { method: "POST" }); + }, + addBrain(input: BrainInput): Promise { + if (window.coworker) return window.coworker.brain.add(input) as Promise; + return json("/api/brain", { method: "POST", body: JSON.stringify(input) }); + }, + removeBrain(id: string): Promise { + if (window.coworker) return window.coworker.brain.remove(id) as Promise; + return json(`/api/brain/${id}`, { method: "DELETE" }); + }, + openWorkspace(): Promise { + if (window.coworker) return window.coworker.workspace.open() as Promise; + throw new Error("Workspace selection is available in the desktop app."); + }, + listWorkspace(id: string): Promise { + if (window.coworker) return window.coworker.workspace.list(id) as Promise; + return json(`/api/workspaces/${id}/files`); + }, + readWorkspace(id: string, relativePath: string): Promise { + if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise; + return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content); + }, + runAgent(request: AgentRequest): Promise { + if (window.coworker) return window.coworker.agent.run(request) as Promise; + return json("/api/agent/run", { method: "POST", body: JSON.stringify(request) }); + }, + saveWebSettings(settings: WebAccessSettings): Promise { + if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise; + throw new Error("Web settings are available in the desktop app."); + }, + createPairing(): Promise { + if (window.coworker) return window.coworker.web.createPairing() as Promise; + throw new Error("Pairing is started from the desktop app."); + }, + pair(code: string, name: string): Promise<{ token: string }> { + return json<{ token: string }>("/api/pair", { method: "POST", body: JSON.stringify({ code, name }) }); + } +}; + +export function displayBrainItem(item: BrainItem) { + return `${item.kind.toUpperCase()} / ${item.scope}${item.pinned ? " / PINNED" : ""}`; +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts new file mode 100644 index 0000000..43412e1 --- /dev/null +++ b/apps/desktop/src/global.d.ts @@ -0,0 +1,7 @@ +import type { CoworkerApi } from "../electron/preload"; + +declare global { + interface Window { + coworker?: CoworkerApi; + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..3254104 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { AppState, BrainItem, ChatMessage, ModelProvider, WorkspaceFile, WorkspaceState } from "@coworker/core"; +import { createProvider } from "@coworker/core"; +import { coworkerApi, displayBrainItem } from "./api"; +import "./styles.css"; + +type View = "agent" | "models" | "brain" | "files" | "settings"; + +const emptyState: AppState = { + providers: [], + brain: [], + workspaces: [], + webAccess: { enabled: false, bindHost: "127.0.0.1", port: 4789, requirePairing: true }, + pairedDevices: [] +}; + +function App() { + const [state, setState] = useState(emptyState); + const [view, setView] = useState("agent"); + const [error, setError] = useState(""); + const [workspace, setWorkspace] = useState(); + + async function refresh() { + try { + setState(await coworkerApi.state()); + setError(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load Coworker state."); + } + } + + useEffect(() => { + void refresh(); + if ("serviceWorker" in navigator) void navigator.serviceWorker.register("/sw.js").catch(() => undefined); + }, []); + + useEffect(() => { + setWorkspace(state.workspaces[0]); + }, [state.workspaces]); + + const activeProvider = state.providers.find((provider) => provider.enabled) ?? state.providers[0]; + + return ( +
+ + +
+
+
+

{label(view)}

+

{subtitle(view, workspace)}

+
+ {error ? {error} : Ready} +
+ + {view === "agent" && } + {view === "files" && } + {view === "models" && } + {view === "brain" && } + {view === "settings" && } +
+
+ ); +} + +function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) { + const [prompt, setPrompt] = useState(""); + const [messages, setMessages] = useState([]); + const [running, setRunning] = useState(false); + const provider = state.providers.find((item) => item.enabled) ?? state.providers[0]; + const model = provider?.defaults.coding ?? provider?.defaults.chat; + + async function run() { + if (!prompt.trim()) return; + setRunning(true); + const userMessage: ChatMessage = { + id: `local_${Date.now()}`, + role: "user", + content: prompt, + createdAt: new Date().toISOString() + }; + setMessages((current) => [...current, userMessage]); + setPrompt(""); + try { + const result = await coworkerApi.runAgent({ prompt, providerId: provider?.id, model, workspace, messages }); + setMessages((current) => [...current, result.assistantMessage]); + onError(""); + } catch (err) { + onError(err instanceof Error ? err.message : "Agent failed."); + } finally { + setRunning(false); + } + } + + return ( +
+
+ {messages.length === 0 ? ( +
+

Start a coding session

+

Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.

+
+ ) : messages.map((message) => ( +
+ {message.role} +
{message.content}
+
+ ))} +
+