Initial Coworker app scaffold
This commit is contained in:
75
.codex/project.md
Normal file
75
.codex/project.md
Normal 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.
|
||||||
47
.gitea/workflows/build.yml
Normal file
47
.gitea/workflows/build.yml
Normal 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
|
||||||
22
.gitea/workflows/dependency-check.yml
Normal file
22
.gitea/workflows/dependency-check.yml
Normal 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
|
||||||
31
.gitea/workflows/release-dry-run.yml
Normal file
31
.gitea/workflows/release-dry-run.yml
Normal 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
|
||||||
72
.gitea/workflows/release.yml
Normal file
72
.gitea/workflows/release.yml
Normal 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
|
||||||
27
.gitea/workflows/repo-cleanup.yml
Normal file
27
.gitea/workflows/repo-cleanup.yml
Normal 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
|
||||||
30
.gitea/workflows/security-scan.yml
Normal file
30
.gitea/workflows/security-scan.yml
Normal 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
|
||||||
35
.gitea/workflows/template-compliance.yml
Normal file
35
.gitea/workflows/template-compliance.yml
Normal 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
17
.gitignore
vendored
Normal 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
59
AGENTS.md
Normal 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
6
CHANGELOG.md
Normal 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
30
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
72
README.md
Normal 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
29
SECURITY.md
Normal 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.
|
||||||
416
apps/desktop/electron/main.ts
Normal file
416
apps/desktop/electron/main.ts
Normal 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();
|
||||||
|
});
|
||||||
31
apps/desktop/electron/preload.ts
Normal file
31
apps/desktop/electron/preload.ts
Normal 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
17
apps/desktop/index.html
Normal 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>
|
||||||
6
apps/desktop/package.json
Normal file
6
apps/desktop/package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@coworker/desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
5
apps/desktop/public/icon.svg
Normal file
5
apps/desktop/public/icon.svg
Normal 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 |
17
apps/desktop/public/manifest.webmanifest
Normal file
17
apps/desktop/public/manifest.webmanifest
Normal 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
13
apps/desktop/public/sw.js
Normal 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
86
apps/desktop/src/api.ts
Normal 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
7
apps/desktop/src/global.d.ts
vendored
Normal 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
310
apps/desktop/src/main.tsx
Normal 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
461
apps/desktop/src/styles.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/desktop/tsconfig.electron.json
Normal file
13
apps/desktop/tsconfig.electron.json
Normal 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"]
|
||||||
|
}
|
||||||
16
apps/desktop/tsconfig.json
Normal file
16
apps/desktop/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
apps/desktop/vite.config.ts
Normal file
21
apps/desktop/vite.config.ts
Normal 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
17
docs/agent-handoff.md
Normal 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
10
docs/release-checklist.md
Normal 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
17
docs/release-notes.md
Normal 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
19
docs/security-review.md
Normal 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
77
package.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/core/package.json
Normal file
10
packages/core/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/core/src/agent.ts
Normal file
60
packages/core/src/agent.ts
Normal 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();
|
||||||
|
}
|
||||||
26
packages/core/src/brain.test.ts
Normal file
26
packages/core/src/brain.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
76
packages/core/src/brain.ts
Normal file
76
packages/core/src/brain.ts
Normal 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
9
packages/core/src/ids.ts
Normal 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();
|
||||||
|
}
|
||||||
6
packages/core/src/index.ts
Normal file
6
packages/core/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./agent";
|
||||||
|
export * from "./brain";
|
||||||
|
export * from "./ids";
|
||||||
|
export * from "./pairing";
|
||||||
|
export * from "./providers";
|
||||||
|
export * from "./types";
|
||||||
30
packages/core/src/pairing.ts
Normal file
30
packages/core/src/pairing.ts
Normal 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();
|
||||||
|
}
|
||||||
65
packages/core/src/providers.ts
Normal file
65
packages/core/src/providers.ts
Normal 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
129
packages/core/src/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
13
packages/core/tsconfig.json
Normal file
13
packages/core/tsconfig.json
Normal 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
19
tsconfig.base.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "packages/core" },
|
||||||
|
{ "path": "apps/desktop" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user