Initial Coworker app scaffold
Some checks failed
Codex Template Compliance / compliance (push) Successful in 7s
Release Dry Run / release-dry-run (push) Failing after 44s
Build / build (push) Failing after 45s

This commit is contained in:
2026-06-19 01:07:16 +02:00
commit 2ec18964b7
46 changed files with 2582 additions and 0 deletions

75
.codex/project.md Normal file
View File

@@ -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-<version>.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-<version>.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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 - <<PY
import json, os
print(json.dumps({
"tag_name": os.environ.get("tag", "$tag"),
"target_commitish": "main",
"name": "$tag",
"body": """$body""",
"draft": False,
"prerelease": False
}))
PY
)"
response="$(curl --fail-with-body -sS \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$release_json" \
"$server/api/v1/repos/$repo/releases")"
release_id="$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
for file in release/Coworker-Setup-*.exe release/SHA256SUMS.txt; do
name="$(basename "$file")"
curl --fail-with-body -sS \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$file" \
"$server/api/v1/repos/$repo/releases/$release_id/assets?name=$name"
done

View File

@@ -0,0 +1,27 @@
name: Scheduled Repository Cleanup Check
on:
schedule:
- cron: "43 3 * * 1"
workflow_dispatch:
jobs:
cleanup-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Report generated files
run: |
find . -path ./.git -prune -o \( -name node_modules -o -name dist -o -name dist-electron -o -name release -o -name coverage \) -print
- name: Report large tracked files
run: |
git ls-files -z | xargs -0 du -h | awk '$1 ~ /[0-9]+M/ { print }' || true
- name: Verify ignored local outputs
run: |
grep -q "node_modules/" .gitignore
grep -q "release/" .gitignore
grep -q ".env" .gitignore

View File

@@ -0,0 +1,30 @@
name: Scheduled Security Scan
on:
schedule:
- cron: "17 3 * * 1"
workflow_dispatch:
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Scan suspicious patterns
run: |
rg -n "eval\\(|new Function|innerHTML\\s*=|shell:\\s*true|LOCALHOST_BYPASS|coworker_token" apps packages docs || true
- name: Scan secret-prone files
run: |
if find . -type f \( -name ".env" -o -name "*.pem" -o -name "*.pfx" -o -name "*.p12" -o -name "*.key" \) | rg .; then
echo "Secret-prone file is tracked or present in workspace"
exit 1
fi
- name: Install dependencies
run: npm install
- name: Audit
run: npm run audit

View File

@@ -0,0 +1,35 @@
name: Codex Template Compliance
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
compliance:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Required files
run: |
test -f AGENTS.md
test -f .codex/project.md
test -f README.md
test -f SECURITY.md
test -f CHANGELOG.md
test -f docs/release-checklist.md
test -f docs/security-review.md
test -f docs/agent-handoff.md
- name: Placeholder scan
run: |
if rg "PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|PACKAGE_NAME|ARTIFACT_NAME|ARTIFACT_OUTPUT_DIRECTORY|AUTHOR_NAME|PROJECT_STACK|DOWNLOAD_URL|CI_URL|RELEASES_URL|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND|README_COMMAND|INSTALL_COMMAND|DEV_COMMAND|PACKAGE_MANAGER|PROJECT_VERSION|COMMIT_OR_VERSION" .; then
echo "Unresolved Codex kit placeholder found"
exit 1
fi
- name: Lightweight diff hygiene
run: git diff --check

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
node_modules/
dist/
dist-electron/
release/
coverage/
.vite/
.env
.env.*
!.env.example
*.log
*.tsbuildinfo
data/
logs/
*.pfx
*.p12
*.key
*.pem

59
AGENTS.md Normal file
View File

@@ -0,0 +1,59 @@
# Agent Instructions
## Project
Coworker is a Windows-first local AI coding app with a Codex-style desktop UX, local model provider setup, an Odysseus-inspired Brain, and optional iPhone/iPad PWA access.
Repository:
```text
Code-Inc/Coworker
```
## Repository Rules
- Start every task with `git status --short --branch`.
- If the tree is clean, use a safe fast-forward update check before editing.
- Preserve unrelated user changes.
- Do not commit secrets, `.env` files, private keys, certificates, API tokens, local app data, or generated installers outside release artifacts.
- Use only Gitea Ubuntu runners for project builds, tests, dependency setup, package jobs, installers, and releases.
- Do not run dependency installs, Electron builds, test suites, package jobs, or installer builds on the user's local machine.
- Local checks are limited to lightweight reads and validation such as `rg`, JSON parsing, TypeScript config inspection, and `git diff --check`.
- Keep workflows on supported Gitea runner labels: `ubuntu-latest`, `ubuntu-24.04`, or `ubuntu-22.04`.
- Do not add Windows or macOS runners. Use Linux-compatible Electron packaging with Wine/Docker on Gitea runners.
- Keep release artifacts free of Codex kit metadata such as `AGENTS.md`, `.codex/`, workflow templates, and handoff docs.
## Commands
Run these through Gitea Actions, not locally:
```bash
npm install
npm run lint
npm run test
npm run build
npm run package:win
npm run audit
```
Lightweight local validation:
```bash
git diff --check
```
## Architecture Rules
- Electron + React + TypeScript is the product stack.
- Avoid native Node dependencies in v1 unless they have reliable Windows prebuilds and package cleanly from Ubuntu runners.
- The first installer target is unsigned NSIS `.exe`; MSI follows after the EXE pipeline is stable.
- Web/PWA access must be disabled by default and protected by pairing-code authentication.
- Workspace file access must stay inside explicitly selected project roots.
- Agent file writes, command execution, and destructive operations require visible user approval.
## Finish Checklist
- `git diff --check` passes.
- Gitea runner workflows are configured for build, security, dependency, cleanup, release dry-run, and template compliance.
- Release docs explain how the Windows installer is produced and where artifacts appear.
- If a commit is pushed and triggers workflows, poll the workflow to success or document the blocker.

6
CHANGELOG.md Normal file
View File

@@ -0,0 +1,6 @@
# Changelog
## 0.1.0
- Initial Coworker scaffold.
- Added Electron desktop shell, React UI, local provider setup, Brain, PWA web access, and Gitea installer workflows.

30
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,30 @@
# Contributing
Coworker is built through Gitea Actions. Keep local work lightweight and use the Ubuntu runners for dependency setup, tests, builds, packaging, and release checks.
## Workflow
1. Check `git status --short --branch`.
2. Keep changes focused.
3. Update docs when behavior or release artifacts change.
4. Push and watch Gitea workflows.
5. Fix failed runner checks before considering work complete.
## Commands
Run on Gitea runners:
```bash
npm install
npm run lint
npm run test
npm run build
npm run package:win
npm run audit
```
Local:
```bash
git diff --check
```

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Code-Inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

72
README.md Normal file
View File

@@ -0,0 +1,72 @@
# Coworker
Coworker is a Windows-first local AI coding app. It is designed like a focused desktop coding assistant: open a project, connect a local model, talk to the agent, inspect files, review diffs, and keep useful context in Brain.
## Features
- Codex-style desktop workflow for coding sessions.
- Local model provider setup for Ollama, LM Studio, and custom OpenAI-compatible endpoints.
- Brain with persistent memories and reusable skills.
- Workspace file preview with path confinement.
- Optional Web/PWA access for iPhone and iPad, disabled by default and protected by pairing codes.
- Windows installer produced by Gitea Actions on Ubuntu runners.
## Development
Project setup, dependency installation, builds, tests, audits, packaging, and releases run in Gitea Actions. Do not run heavy project commands on the user's local machine.
Runner commands:
```bash
npm install
npm run lint
npm run test
npm run build
npm run package:win
```
Lightweight local validation:
```bash
git diff --check
```
## Windows Installer
The first release artifact is:
```text
release/Coworker-Setup-<version>.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.

29
SECURITY.md Normal file
View File

@@ -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.

View File

@@ -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<AppState> {
try {
return { ...defaultState, ...JSON.parse(await readFile(stateFile(), "utf8")) };
} catch {
return defaultState;
}
}
async function writeState(state: AppState): Promise<AppState> {
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<AppState>): Promise<AppState> {
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<BrainInput>) => 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<void>((resolve) => webServer?.close(() => resolve()));
webServer = undefined;
}
if (!settings.enabled) return;
webServer = createServer(handleWebRequest);
await new Promise<void>((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<ModelProvider>(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<BrainInput>(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<T>(req: IncomingMessage): Promise<T> {
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();
});

View File

@@ -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;

17
apps/desktop/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#111317" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Coworker" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Coworker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "@coworker/desktop",
"version": "0.1.0",
"type": "module",
"private": true
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="28" fill="#111317"/>
<path d="M31 70c0-23 14-39 34-39 16 0 27 9 31 23H77c-3-5-7-7-13-7-10 0-16 9-16 23s6 23 16 23c7 0 12-3 15-10h19c-5 17-17 27-35 27-19 0-32-16-32-40Z" fill="#f5f7fb"/>
<path d="M22 24h28v13H35v54h15v13H22V24Zm84 80H78V91h15V37H78V24h28v80Z" fill="#7dd3fc"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@@ -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"
}
]
}

13
apps/desktop/public/sw.js Normal file
View File

@@ -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("/"))));
});

86
apps/desktop/src/api.ts Normal file
View File

@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
}
export const coworkerApi = {
state(): Promise<AppState> {
if (window.coworker) return window.coworker.state() as Promise<AppState>;
return json<AppState>("/api/state");
},
saveProvider(provider: ModelProvider): Promise<AppState> {
if (window.coworker) return window.coworker.providers.save(provider) as Promise<AppState>;
return json<AppState>("/api/providers", { method: "POST", body: JSON.stringify(provider) });
},
probeProvider(id: string): Promise<ProviderProbeResult> {
if (window.coworker) return window.coworker.providers.probe(id) as Promise<ProviderProbeResult>;
return json<ProviderProbeResult>(`/api/providers/${id}/probe`, { method: "POST" });
},
addBrain(input: BrainInput): Promise<AppState> {
if (window.coworker) return window.coworker.brain.add(input) as Promise<AppState>;
return json<AppState>("/api/brain", { method: "POST", body: JSON.stringify(input) });
},
removeBrain(id: string): Promise<AppState> {
if (window.coworker) return window.coworker.brain.remove(id) as Promise<AppState>;
return json<AppState>(`/api/brain/${id}`, { method: "DELETE" });
},
openWorkspace(): Promise<WorkspaceState | undefined> {
if (window.coworker) return window.coworker.workspace.open() as Promise<WorkspaceState | undefined>;
throw new Error("Workspace selection is available in the desktop app.");
},
listWorkspace(id: string): Promise<WorkspaceFile[]> {
if (window.coworker) return window.coworker.workspace.list(id) as Promise<WorkspaceFile[]>;
return json<WorkspaceFile[]>(`/api/workspaces/${id}/files`);
},
readWorkspace(id: string, relativePath: string): Promise<string> {
if (window.coworker) return window.coworker.workspace.read(id, relativePath) as Promise<string>;
return json<{ content: string }>(`/api/workspaces/${id}/files/${encodeURIComponent(relativePath)}`).then((r) => r.content);
},
runAgent(request: AgentRequest): Promise<AgentRunResult> {
if (window.coworker) return window.coworker.agent.run(request) as Promise<AgentRunResult>;
return json<AgentRunResult>("/api/agent/run", { method: "POST", body: JSON.stringify(request) });
},
saveWebSettings(settings: WebAccessSettings): Promise<AppState> {
if (window.coworker) return window.coworker.web.saveSettings(settings) as Promise<AppState>;
throw new Error("Web settings are available in the desktop app.");
},
createPairing(): Promise<PairingChallenge> {
if (window.coworker) return window.coworker.web.createPairing() as Promise<PairingChallenge>;
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" : ""}`;
}

7
apps/desktop/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import type { CoworkerApi } from "../electron/preload";
declare global {
interface Window {
coworker?: CoworkerApi;
}
}

310
apps/desktop/src/main.tsx Normal file
View File

@@ -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<AppState>(emptyState);
const [view, setView] = useState<View>("agent");
const [error, setError] = useState("");
const [workspace, setWorkspace] = useState<WorkspaceState | undefined>();
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 (
<main className="shell">
<aside className="sidebar">
<div className="brand">
<span className="brand-mark">C</span>
<div>
<strong>Coworker</strong>
<span>Local coding app</span>
</div>
</div>
<nav className="nav">
{(["agent", "files", "models", "brain", "settings"] as View[]).map((item) => (
<button key={item} className={view === item ? "active" : ""} onClick={() => setView(item)}>
{label(item)}
</button>
))}
</nav>
<div className="status">
<span>Model</span>
<strong>{activeProvider?.name ?? "Not configured"}</strong>
</div>
<button className="primary" onClick={async () => setWorkspace(await coworkerApi.openWorkspace())}>
Open project
</button>
</aside>
<section className="content">
<header className="topbar">
<div>
<h1>{label(view)}</h1>
<p>{subtitle(view, workspace)}</p>
</div>
{error ? <span className="error">{error}</span> : <span className="ok">Ready</span>}
</header>
{view === "agent" && <AgentView state={state} workspace={workspace} onError={setError} />}
{view === "files" && <FilesView workspace={workspace} onError={setError} />}
{view === "models" && <ModelsView providers={state.providers} onState={setState} onError={setError} />}
{view === "brain" && <BrainView items={state.brain} onState={setState} onError={setError} />}
{view === "settings" && <SettingsView state={state} onState={setState} onError={setError} />}
</section>
</main>
);
}
function AgentView({ state, workspace, onError }: { state: AppState; workspace?: WorkspaceState; onError: (value: string) => void }) {
const [prompt, setPrompt] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 (
<div className="agent-grid">
<section className="conversation">
{messages.length === 0 ? (
<div className="empty">
<h2>Start a coding session</h2>
<p>Open a project, pick a local model, and ask Coworker to inspect, plan, or propose changes.</p>
</div>
) : messages.map((message) => (
<article key={message.id} className={`message ${message.role}`}>
<span>{message.role}</span>
<pre>{message.content}</pre>
</article>
))}
<div className="composer">
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Coworker to work on this project..." />
<button className="primary" disabled={running} onClick={() => void run()}>{running ? "Running" : "Send"}</button>
</div>
</section>
<aside className="inspector">
<h2>Session</h2>
<dl>
<dt>Project</dt>
<dd>{workspace?.name ?? "None"}</dd>
<dt>Provider</dt>
<dd>{provider?.name ?? "None"}</dd>
<dt>Model</dt>
<dd>{model ?? "Choose in Models"}</dd>
</dl>
<h2>Brain recall</h2>
<ul className="compact-list">
{state.brain.filter((item) => item.pinned).slice(0, 6).map((item) => <li key={item.id}>{item.title}</li>)}
</ul>
</aside>
</div>
);
}
function FilesView({ workspace, onError }: { workspace?: WorkspaceState; onError: (value: string) => void }) {
const [files, setFiles] = useState<WorkspaceFile[]>([]);
const [preview, setPreview] = useState("");
useEffect(() => {
if (!workspace) return;
coworkerApi.listWorkspace(workspace.id).then(setFiles).catch((err) => onError(err instanceof Error ? err.message : "File list failed."));
}, [workspace, onError]);
return (
<div className="split">
<section className="list-panel">
{files.map((file) => (
<button key={file.path} onClick={async () => {
if (file.type === "directory" || !workspace) return;
setPreview(await coworkerApi.readWorkspace(workspace.id, file.path));
}}>
<span>{file.type === "directory" ? "DIR" : "FILE"}</span>
{file.path}
</button>
))}
</section>
<pre className="preview">{preview || "Open a text file to preview it."}</pre>
</div>
);
}
function ModelsView({ providers, onState, onError }: { providers: ModelProvider[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
const [draft, setDraft] = useState<ModelProvider>(() => createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
const [probe, setProbe] = useState("");
return (
<div className="settings-grid">
<section>
<h2>Providers</h2>
<div className="provider-list">
{providers.map((provider) => (
<article key={provider.id}>
<strong>{provider.name}</strong>
<span>{provider.baseUrl}</span>
<button onClick={async () => {
const result = await coworkerApi.probeProvider(provider.id);
setProbe(`${result.message} ${result.models.join(", ")}`);
}}>Test</button>
</article>
))}
</div>
</section>
<section>
<h2>Add endpoint</h2>
<input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
<input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
<button className="primary" onClick={async () => {
try {
onState(await coworkerApi.saveProvider(draft));
setDraft(createProvider("openai-compatible", "Custom endpoint", "http://localhost:8000/v1"));
} catch (err) {
onError(err instanceof Error ? err.message : "Provider save failed.");
}
}}>Save provider</button>
<p className="muted">{probe || "Ollama and LM Studio defaults are created on first launch."}</p>
</section>
</div>
);
}
function BrainView({ items, onState, onError }: { items: BrainItem[]; onState: (state: AppState) => void; onError: (value: string) => void }) {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [kind, setKind] = useState<"memory" | "skill">("memory");
return (
<div className="settings-grid">
<section>
<h2>Brain</h2>
<div className="brain-list">
{items.map((item) => (
<article key={item.id}>
<span>{displayBrainItem(item)}</span>
<strong>{item.title}</strong>
<p>{item.body}</p>
<button onClick={async () => onState(await coworkerApi.removeBrain(item.id))}>Remove</button>
</article>
))}
</div>
</section>
<section>
<h2>Add Brain item</h2>
<div className="segmented">
<button className={kind === "memory" ? "active" : ""} onClick={() => setKind("memory")}>Memory</button>
<button className={kind === "skill" ? "active" : ""} onClick={() => setKind("skill")}>Skill</button>
</div>
<input value={title} placeholder="Title" onChange={(event) => setTitle(event.target.value)} />
<textarea value={body} placeholder="What should Coworker remember or know how to do?" onChange={(event) => setBody(event.target.value)} />
<button className="primary" onClick={async () => {
try {
onState(await coworkerApi.addBrain({ kind, scope: "global", title, body, pinned: kind === "skill" }));
setTitle("");
setBody("");
} catch (err) {
onError(err instanceof Error ? err.message : "Brain save failed.");
}
}}>Save</button>
</section>
</div>
);
}
function SettingsView({ state, onState, onError }: { state: AppState; onState: (state: AppState) => void; onError: (value: string) => void }) {
const [settings, setSettings] = useState(state.webAccess);
const [pairing, setPairing] = useState("");
const url = useMemo(() => `http://${settings.bindHost === "0.0.0.0" ? "localhost" : settings.bindHost}:${settings.port}`, [settings]);
return (
<div className="settings-grid">
<section>
<h2>Web access</h2>
<label className="switch"><input type="checkbox" checked={settings.enabled} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /> Enable Web/PWA access</label>
<label>Bind host</label>
<select value={settings.bindHost} onChange={(event) => setSettings({ ...settings, bindHost: event.target.value as "127.0.0.1" | "0.0.0.0" })}>
<option value="127.0.0.1">Localhost only</option>
<option value="0.0.0.0">LAN / Tailscale</option>
</select>
<label>Port</label>
<input type="number" value={settings.port} min={1024} max={65535} onChange={(event) => setSettings({ ...settings, port: Number(event.target.value) })} />
<button className="primary" onClick={async () => {
try {
onState(await coworkerApi.saveWebSettings(settings));
} catch (err) {
onError(err instanceof Error ? err.message : "Web access update failed.");
}
}}>Apply</button>
<p className="muted">Open {url} on iPhone or iPad after enabling LAN access.</p>
</section>
<section>
<h2>Pair device</h2>
<button onClick={async () => {
const challenge = await coworkerApi.createPairing();
setPairing(`${challenge.code} expires ${new Date(challenge.expiresAt).toLocaleTimeString()}`);
}}>Create pairing code</button>
<div className="pairing-code">{pairing || "No active code"}</div>
<p className="muted">The mobile webapp stores a scoped device session after pairing.</p>
</section>
</div>
);
}
function label(view: View) {
return ({ agent: "Agent", models: "Models", brain: "Brain", files: "Files", settings: "Settings" })[view];
}
function subtitle(view: View, workspace?: WorkspaceState) {
if (view === "agent") return workspace ? `Working in ${workspace.name}` : "Open a project to start.";
if (view === "models") return "Connect Ollama, LM Studio, or OpenAI-compatible endpoints.";
if (view === "brain") return "Persistent memory, project knowledge, and reusable skills.";
if (view === "files") return workspace ? workspace.rootPath : "Open a project to inspect files.";
return "Desktop, web access, pairing, and release-safe defaults.";
}
createRoot(document.getElementById("root")!).render(<App />);

461
apps/desktop/src/styles.css Normal file
View File

@@ -0,0 +1,461 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111317;
color: #f4f6f8;
--bg: #111317;
--panel: #181b20;
--panel-2: #20242b;
--line: #303640;
--muted: #9ba6b3;
--text: #f4f6f8;
--accent: #7dd3fc;
--accent-2: #84cc16;
--danger: #fb7185;
--radius: 8px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bg);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-2);
color: var(--text);
min-height: 40px;
padding: 0 12px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0f1115;
color: var(--text);
padding: 12px;
}
textarea {
min-height: 120px;
resize: vertical;
}
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
}
.sidebar {
border-right: 1px solid var(--line);
background: #0e1014;
padding: 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #15202a;
color: var(--accent);
font-weight: 800;
}
.brand strong,
.brand span {
display: block;
}
.brand span,
.muted,
.status span,
.message span,
dt {
color: var(--muted);
}
.nav {
display: grid;
gap: 6px;
}
.nav button {
justify-content: flex-start;
text-align: left;
background: transparent;
}
.nav button.active,
.segmented button.active {
border-color: var(--accent);
background: #142333;
}
.status {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
background: var(--panel);
margin-top: auto;
}
.status strong {
display: block;
margin-top: 4px;
}
.primary {
border-color: #24506a;
background: #123047;
color: #e6f7ff;
}
.content {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
}
.topbar {
min-height: 86px;
border-bottom: 1px solid var(--line);
padding: 18px 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 4px;
font-size: 24px;
}
h2 {
font-size: 15px;
margin-bottom: 12px;
}
.topbar p {
margin-bottom: 0;
color: var(--muted);
}
.ok,
.error {
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 10px;
white-space: nowrap;
}
.ok {
color: var(--accent-2);
}
.error {
color: var(--danger);
}
.agent-grid,
.settings-grid,
.split {
min-height: 0;
padding: 18px;
display: grid;
gap: 18px;
}
.agent-grid {
grid-template-columns: minmax(0, 1fr) 320px;
}
.settings-grid,
.split {
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
}
.conversation,
.inspector,
.settings-grid section,
.list-panel,
.preview {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
.conversation {
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.empty {
align-self: center;
justify-self: center;
max-width: 520px;
text-align: center;
color: var(--muted);
}
.message {
margin: 14px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #12161c;
}
.message.assistant {
background: #151d20;
}
.message pre,
.preview {
margin: 0;
white-space: pre-wrap;
overflow: auto;
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
}
.composer {
border-top: 1px solid var(--line);
padding: 12px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.composer textarea {
min-height: 58px;
max-height: 180px;
}
.inspector,
.settings-grid section {
padding: 16px;
overflow: auto;
}
dl {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px 12px;
margin: 0 0 22px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.compact-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
}
.provider-list,
.brain-list {
display: grid;
gap: 10px;
}
.provider-list article,
.brain-list article {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
background: #12161c;
display: grid;
gap: 8px;
}
.provider-list span,
.brain-list span {
color: var(--muted);
overflow-wrap: anywhere;
}
.brain-list p {
margin-bottom: 0;
color: #d6dbe1;
}
.segmented {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 10px;
}
.settings-grid section > * + * {
margin-top: 10px;
}
.switch {
display: flex;
align-items: center;
gap: 10px;
}
.switch input {
width: auto;
}
.pairing-code {
min-height: 72px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #101319;
display: grid;
place-items: center;
font-size: 20px;
color: var(--accent);
}
.list-panel {
padding: 10px;
overflow: auto;
display: grid;
align-content: start;
gap: 6px;
}
.list-panel button {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
text-align: left;
align-items: center;
}
.list-panel span {
color: var(--muted);
font-size: 11px;
}
.preview {
padding: 14px;
min-height: 0;
}
@media (max-width: 880px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
padding-bottom: env(safe-area-inset-bottom);
}
.sidebar {
grid-row: 2;
border-right: 0;
border-top: 1px solid var(--line);
padding: 8px max(8px, env(safe-area-inset-left)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
}
.brand,
.status,
.sidebar .primary {
display: none;
}
.nav {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.nav button {
min-height: 48px;
padding: 0 6px;
text-align: center;
justify-content: center;
}
.content {
min-height: 0;
}
.topbar {
min-height: 72px;
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
}
.topbar p,
.ok,
.error {
display: none;
}
h1 {
font-size: 20px;
}
.agent-grid,
.settings-grid,
.split {
grid-template-columns: 1fr;
padding: 10px;
}
.inspector {
display: none;
}
.composer {
grid-template-columns: 1fr;
}
.settings-grid,
.split {
overflow: auto;
}
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"outDir": "dist-electron",
"rootDir": "electron",
"types": ["node"],
"lib": ["ES2022", "DOM"]
},
"include": ["electron/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@coworker/core": ["../../packages/core/src"],
"@coworker/core/*": ["../../packages/core/src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"electron/**/*.ts",
"vite.config.ts"
]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
export default defineConfig({
root: fileURLToPath(new URL(".", import.meta.url)),
plugins: [react()],
resolve: {
alias: {
"@coworker/core": fileURLToPath(new URL("../../packages/core/src", import.meta.url))
}
},
build: {
outDir: "dist",
emptyOutDir: true
},
server: {
port: 5173,
strictPort: true
}
});

17
docs/agent-handoff.md Normal file
View File

@@ -0,0 +1,17 @@
# Agent Handoff
## Current State
Coworker was scaffolded from an empty repository as an Electron + React + TypeScript app with a shared core package.
## Next Steps
- Push the scaffold to Gitea.
- Let Gitea Actions install dependencies, run checks, build the app, and produce the first Windows NSIS installer.
- If the installer workflow fails, inspect runner logs and fix the workflow or dependency issue in scope.
## Known Risks
- The first installer is unsigned.
- MSI packaging is intentionally deferred until the NSIS `.exe` pipeline is stable.
- Full model downloading/serving is deferred; v1 connects to Ollama, LM Studio, and custom OpenAI-compatible endpoints.

10
docs/release-checklist.md Normal file
View File

@@ -0,0 +1,10 @@
# Release Checklist
- [ ] `CHANGELOG.md` contains the release entry.
- [ ] Gitea build workflow passed.
- [ ] Release dry-run workflow passed.
- [ ] `release/Coworker-Setup-<version>.exe` exists.
- [ ] `release/SHA256SUMS.txt` exists and matches the installer.
- [ ] Installer does not include repository maintenance files such as `.codex/`, `.gitea/`, or `docs/agent-handoff.md`.
- [ ] Release notes mention that the first installer is unsigned.
- [ ] Tag uses `v<version>`.

17
docs/release-notes.md Normal file
View File

@@ -0,0 +1,17 @@
# Release Notes
## Coworker 0.1.0
Initial Windows-first local AI coding app scaffold.
Highlights:
- Electron desktop app with Codex-style workspace shell.
- Local model provider settings for Ollama, LM Studio, and custom endpoints.
- Brain memory and skills.
- Optional paired Web/PWA access for iPhone and iPad.
- Gitea Actions pipeline for Windows setup `.exe`.
Known limitation:
- Installer is unsigned until code signing is configured.

19
docs/security-review.md Normal file
View File

@@ -0,0 +1,19 @@
# Security Review
## Current State
Coworker starts from a local-first security model:
- Web/PWA access is opt-in.
- Pairing tokens are hashed before storage.
- Workspace file access is path-confined.
- Model endpoints are user-configured local or OpenAI-compatible URLs.
## Required Checks Before Release
- Verify web access is disabled by default.
- Verify unauthenticated web API requests return `401`.
- Verify paired device tokens are not stored in plaintext.
- Verify workspace reads reject path traversal.
- Verify build artifacts exclude local app data and repository maintenance files.
- Run scheduled security scan in Gitea Actions.

77
package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "coworker",
"version": "0.1.0",
"description": "Windows-first local AI coding coworker with local model providers, Brain memory, and mobile PWA access.",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "https://git.wilkensxl.de/Code-Inc/Coworker.git"
},
"author": "Code-Inc",
"license": "MIT",
"workspaces": [
"apps/*",
"packages/*"
],
"main": "apps/desktop/dist-electron/main.js",
"scripts": {
"dev": "vite --host 127.0.0.1 --config apps/desktop/vite.config.ts",
"lint": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p apps/desktop/tsconfig.json",
"test": "vitest run",
"build": "tsc -p packages/core/tsconfig.json && tsc -p apps/desktop/tsconfig.electron.json && vite build --config apps/desktop/vite.config.ts",
"package:win": "npm run build && electron-builder --win nsis --x64",
"release:check": "npm run lint && npm run test && npm run build",
"audit": "npm audit --omit=dev --audit-level=high"
},
"dependencies": {
"@coworker/core": "workspace:*"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0",
"typescript": "^5.8.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"electron": "^35.0.0",
"electron-builder": "^25.1.8",
"vitest": "^3.2.0",
"@types/node": "^22.13.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
},
"build": {
"appId": "de.codeinc.coworker",
"productName": "Coworker",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"apps/desktop/dist/**/*",
"apps/desktop/dist-electron/**/*",
"package.json"
],
"extraMetadata": {
"main": "apps/desktop/dist-electron/main.js"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "Coworker-Setup-${version}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "@coworker/core",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
}
}

View File

@@ -0,0 +1,60 @@
import { AgentRunRequest, AgentRunResult, ChatMessage } from "./types";
import { recallBrain } from "./brain";
import { createId, nowIso } from "./ids";
export async function runAgent(
request: AgentRunRequest,
completion: (messages: ChatMessage[]) => Promise<string>
): Promise<AgentRunResult> {
const recalled = recallBrain(request.brain, request.prompt, 8);
const systemContent = [
"You are Coworker, a local-first coding assistant.",
"Show proposed file changes as unified diffs and wait for user approval before writes.",
request.workspace ? `Current workspace: ${request.workspace.name}` : "No workspace is open.",
recalled.length > 0
? `Recalled Brain items:\n${recalled.map((r) => `- ${r.item.title}: ${r.item.body}`).join("\n")}`
: "No Brain items were recalled."
].join("\n\n");
const messages: ChatMessage[] = [
{ id: createId("msg"), role: "system", content: systemContent, createdAt: nowIso() },
...request.messages,
{ id: createId("msg"), role: "user", content: request.prompt, createdAt: nowIso() }
];
const content = await completion(messages);
return {
assistantMessage: {
id: createId("msg"),
role: "assistant",
content,
createdAt: nowIso()
},
recalled,
proposedDiffs: extractDiffs(content),
toolLog: [
{
id: createId("log"),
level: "info",
message: `Recalled ${recalled.length} Brain item(s).`,
createdAt: nowIso()
}
]
};
}
export function extractDiffs(content: string) {
const blocks = [...content.matchAll(/```(?:diff|patch)\n([\s\S]*?)```/g)];
return blocks.map((block, index) => ({
id: createId("diff"),
filePath: inferDiffFile(block[1]) ?? `proposed-change-${index + 1}.patch`,
title: `Proposed change ${index + 1}`,
patch: block[1].trim(),
applied: false
}));
}
function inferDiffFile(patch: string): string | undefined {
const match = patch.match(/^\+\+\+\s+b\/(.+)$/m);
return match?.[1]?.trim();
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { createBrainItem, recallBrain } from "./brain";
describe("Brain recall", () => {
it("returns matching pinned and semantic keyword items", () => {
const items = [
createBrainItem({
kind: "memory",
scope: "global",
title: "Model preference",
body: "User prefers local Ollama models for coding.",
pinned: true
}),
createBrainItem({
kind: "skill",
scope: "project",
title: "Diff workflow",
body: "Always present patches before writing files."
})
];
const recalled = recallBrain(items, "Use ollama and patches");
expect(recalled.map((r) => r.item.title)).toContain("Model preference");
expect(recalled.map((r) => r.item.title)).toContain("Diff workflow");
});
});

View File

@@ -0,0 +1,76 @@
import { BrainItem, BrainItemKind, BrainRecall, BrainScope } from "./types";
import { createId, nowIso } from "./ids";
export interface BrainInput {
kind: BrainItemKind;
scope: BrainScope;
title: string;
body: string;
tags?: string[];
projectId?: string;
sessionId?: string;
pinned?: boolean;
}
export function createBrainItem(input: BrainInput): BrainItem {
const createdAt = nowIso();
return {
id: createId(input.kind),
kind: input.kind,
scope: input.scope,
projectId: input.projectId,
sessionId: input.sessionId,
title: input.title.trim(),
body: input.body.trim(),
tags: normalizeTags(input.tags ?? []),
pinned: Boolean(input.pinned),
createdAt,
updatedAt: createdAt
};
}
export function updateBrainItem(item: BrainItem, patch: Partial<BrainInput>): BrainItem {
return {
...item,
title: patch.title === undefined ? item.title : patch.title.trim(),
body: patch.body === undefined ? item.body : patch.body.trim(),
tags: patch.tags === undefined ? item.tags : normalizeTags(patch.tags),
pinned: patch.pinned === undefined ? item.pinned : Boolean(patch.pinned),
updatedAt: nowIso()
};
}
export function recallBrain(items: BrainItem[], query: string, limit = 8): BrainRecall[] {
const terms = tokenize(query);
if (terms.length === 0) {
return items.filter((item) => item.pinned).slice(0, limit).map((item) => ({
item,
score: 1,
matchedTerms: ["pinned"]
}));
}
return items
.map((item) => {
const haystack = tokenize(`${item.title} ${item.body} ${item.tags.join(" ")}`);
const matchedTerms = terms.filter((term) => haystack.includes(term));
const pinBoost = item.pinned ? 0.5 : 0;
const skillBoost = item.kind === "skill" ? 0.2 : 0;
return {
item,
score: matchedTerms.length + pinBoost + skillBoost,
matchedTerms
};
})
.filter((recall) => recall.score > 0)
.sort((a, b) => b.score - a.score || b.item.updatedAt.localeCompare(a.item.updatedAt))
.slice(0, limit);
}
export function normalizeTags(tags: string[]): string[] {
return Array.from(new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))).sort();
}
export function tokenize(text: string): string[] {
return Array.from(new Set(text.toLowerCase().match(/[a-z0-9_äöüß-]{2,}/gi) ?? []));
}

9
packages/core/src/ids.ts Normal file
View File

@@ -0,0 +1,9 @@
export function createId(prefix: string): string {
const random = Math.random().toString(36).slice(2, 10);
const time = Date.now().toString(36);
return `${prefix}_${time}_${random}`;
}
export function nowIso(): string {
return new Date().toISOString();
}

View File

@@ -0,0 +1,6 @@
export * from "./agent";
export * from "./brain";
export * from "./ids";
export * from "./pairing";
export * from "./providers";
export * from "./types";

View File

@@ -0,0 +1,30 @@
import { PairedDevice, PairingChallenge } from "./types";
import { createId, nowIso } from "./ids";
export function createPairingChallenge(ttlMs = 5 * 60 * 1000): PairingChallenge {
const code = String(Math.floor(100000 + Math.random() * 900000));
return {
id: createId("pair"),
code,
expiresAt: new Date(Date.now() + ttlMs).toISOString()
};
}
export async function createPairedDevice(
name: string,
token: string,
digest: (value: string) => Promise<string>
): Promise<PairedDevice> {
return {
id: createId("device"),
name: name.trim() || "Paired device",
tokenHash: await digest(token),
createdAt: nowIso()
};
}
export function isPairingChallengeValid(challenge: PairingChallenge | undefined, code: string): boolean {
if (!challenge) return false;
if (challenge.code !== code.trim()) return false;
return Date.parse(challenge.expiresAt) > Date.now();
}

View File

@@ -0,0 +1,65 @@
import { ModelProvider, ProviderProbeResult } from "./types";
import { createId, nowIso } from "./ids";
export function createProvider(
kind: ModelProvider["kind"],
name: string,
baseUrl: string
): ModelProvider {
const createdAt = nowIso();
return {
id: createId("provider"),
name: name.trim(),
kind,
baseUrl: normalizeBaseUrl(baseUrl),
enabled: true,
supportsTools: kind !== "ollama",
contextWindow: 8192,
defaults: {},
createdAt,
updatedAt: createdAt
};
}
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
export async function probeOpenAiCompatibleProvider(
provider: ModelProvider,
fetchImpl: typeof fetch = fetch
): Promise<ProviderProbeResult> {
const url = `${normalizeBaseUrl(provider.baseUrl)}/models`;
try {
const response = await fetchImpl(url, {
headers: provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : undefined
});
if (!response.ok) {
return {
ok: false,
providerId: provider.id,
models: [],
message: `Provider returned HTTP ${response.status}`
};
}
const body = await response.json() as { data?: Array<{ id?: string }>; models?: string[] };
const models = Array.isArray(body.data)
? body.data.map((model) => model.id).filter(Boolean) as string[]
: Array.isArray(body.models)
? body.models
: [];
return {
ok: models.length > 0,
providerId: provider.id,
models,
message: models.length > 0 ? "Provider is reachable." : "Provider responded without model ids."
};
} catch (error) {
return {
ok: false,
providerId: provider.id,
models: [],
message: error instanceof Error ? error.message : "Provider probe failed."
};
}
}

129
packages/core/src/types.ts Normal file
View File

@@ -0,0 +1,129 @@
export type ModelProviderKind = "ollama" | "lmstudio" | "openai-compatible";
export type ModelPurpose = "chat" | "coding" | "reasoning" | "embedding";
export interface ModelProvider {
id: string;
name: string;
kind: ModelProviderKind;
baseUrl: string;
apiKey?: string;
enabled: boolean;
supportsTools: boolean;
contextWindow: number;
defaults: Partial<Record<ModelPurpose, string>>;
createdAt: string;
updatedAt: string;
}
export interface ProviderProbeResult {
ok: boolean;
providerId: string;
models: string[];
message: string;
}
export type BrainScope = "global" | "project" | "session";
export type BrainItemKind = "memory" | "skill";
export interface BrainItem {
id: string;
kind: BrainItemKind;
scope: BrainScope;
projectId?: string;
sessionId?: string;
title: string;
body: string;
tags: string[];
pinned: boolean;
createdAt: string;
updatedAt: string;
}
export interface BrainRecall {
item: BrainItem;
score: number;
matchedTerms: string[];
}
export interface ChatMessage {
id: string;
role: "system" | "user" | "assistant" | "tool";
content: string;
createdAt: string;
}
export interface WorkspaceFile {
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: string;
}
export interface WorkspaceState {
id: string;
name: string;
rootPath: string;
openedAt: string;
}
export interface AgentRunRequest {
prompt: string;
workspace?: WorkspaceState;
provider?: ModelProvider;
model?: string;
messages: ChatMessage[];
brain: BrainItem[];
}
export interface AgentRunResult {
assistantMessage: ChatMessage;
recalled: BrainRecall[];
proposedDiffs: ProposedDiff[];
toolLog: ToolLogEntry[];
}
export interface ProposedDiff {
id: string;
filePath: string;
title: string;
patch: string;
applied: boolean;
}
export interface ToolLogEntry {
id: string;
level: "info" | "warning" | "error";
message: string;
createdAt: string;
}
export interface WebAccessSettings {
enabled: boolean;
bindHost: "127.0.0.1" | "0.0.0.0";
port: number;
requirePairing: boolean;
}
export interface PairedDevice {
id: string;
name: string;
tokenHash: string;
createdAt: string;
lastSeenAt?: string;
revokedAt?: string;
}
export interface PairingChallenge {
id: string;
code: string;
expiresAt: string;
}
export interface AppState {
providers: ModelProvider[];
brain: BrainItem[];
workspaces: WorkspaceState[];
webAccess: WebAccessSettings;
pairedDevices: PairedDevice[];
}

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

19
tsconfig.base.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
}
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "packages/core" },
{ "path": "apps/desktop" }
]
}