@worca/app 1.4.0-rc.1 → 1.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +36 -1
  2. package/docker/.env.example +54 -0
  3. package/docker/compose.clonein.yml +24 -0
  4. package/docker/compose.dev.yml +24 -0
  5. package/docker/compose.egress.yml +47 -0
  6. package/docker/compose.ssh.yml +17 -0
  7. package/docker/compose.teams.yml +25 -0
  8. package/docker/compose.yml +57 -0
  9. package/package.json +4 -1
  10. package/src/cli/container.mjs +227 -0
  11. package/src/cli/models.mjs +5 -4
  12. package/src/cli/schedule.mjs +23 -10
  13. package/src/cli/worca-cc.mjs +62 -4
  14. package/src/core/agent-user.mjs +92 -0
  15. package/src/core/artifacts.mjs +41 -9
  16. package/src/core/ask/clone-deps.mjs +25 -0
  17. package/src/core/ask/clone-proposal.mjs +79 -0
  18. package/src/core/ask/events.mjs +10 -0
  19. package/src/core/ask/mcp-stdio.mjs +4 -2
  20. package/src/core/ask/model-proposal.mjs +14 -3
  21. package/src/core/ask/prompt.mjs +27 -6
  22. package/src/core/ask/schedule-deps.mjs +16 -11
  23. package/src/core/ask/spawn.mjs +8 -3
  24. package/src/core/ask/store.mjs +40 -10
  25. package/src/core/ask/tool-deps.mjs +36 -1
  26. package/src/core/ask/tools.mjs +69 -8
  27. package/src/core/ask/turn.mjs +31 -1
  28. package/src/core/bridge/errors.mjs +50 -2
  29. package/src/core/bridge/provider-ops.mjs +20 -7
  30. package/src/core/bridge/providers/copilot.mjs +51 -5
  31. package/src/core/bridge/translate/common.mjs +144 -0
  32. package/src/core/bridge/translate/request.mjs +16 -73
  33. package/src/core/bridge/translate/responses-request.mjs +185 -0
  34. package/src/core/bridge/translate/responses-stream.mjs +327 -0
  35. package/src/core/bridge/upstream.mjs +37 -10
  36. package/src/core/cf-access.mjs +90 -0
  37. package/src/core/chat/command-router.mjs +17 -12
  38. package/src/core/claude-runner.mjs +26 -3
  39. package/src/core/clone-project.mjs +139 -0
  40. package/src/core/config.mjs +16 -9
  41. package/src/core/db.mjs +60 -5
  42. package/src/core/deployment.mjs +33 -0
  43. package/src/core/diff-comments.mjs +17 -8
  44. package/src/core/git-info.mjs +27 -7
  45. package/src/core/github-app.mjs +107 -0
  46. package/src/core/github-credentials.mjs +93 -0
  47. package/src/core/graph/executor.mjs +22 -4
  48. package/src/core/graph/script-runner.mjs +24 -3
  49. package/src/core/guardrails.mjs +1 -0
  50. package/src/core/identity.mjs +83 -0
  51. package/src/core/metrics/pr-events-workflow.yml +127 -0
  52. package/src/core/metrics/prs.mjs +383 -0
  53. package/src/core/metrics/read.mjs +39 -29
  54. package/src/core/metrics/record.mjs +30 -2
  55. package/src/core/metrics/sync.mjs +35 -4
  56. package/src/core/model-env.mjs +47 -7
  57. package/src/core/model-test.mjs +29 -5
  58. package/src/core/notifications.mjs +36 -14
  59. package/src/core/orchestrator.mjs +10 -4
  60. package/src/core/phases.mjs +2 -0
  61. package/src/core/pipeline-delete.mjs +4 -3
  62. package/src/core/plugin-repo.mjs +7 -3
  63. package/src/core/policy/gate.mjs +9 -5
  64. package/src/core/policy/state.mjs +2 -2
  65. package/src/core/policy/sync.mjs +5 -4
  66. package/src/core/remote-access.mjs +138 -0
  67. package/src/core/run-harness.mjs +71 -9
  68. package/src/core/scheduler.mjs +63 -33
  69. package/src/shared/team-metrics/timeline.mjs +330 -0
  70. package/ui/public/app.js +591 -14
  71. package/ui/public/ask-panel.mjs +65 -3
  72. package/ui/public/bridge-view.mjs +72 -14
  73. package/ui/public/index.html +42 -9
  74. package/ui/public/schedules-view.mjs +21 -2
  75. package/ui/public/session-guard.mjs +77 -0
  76. package/ui/public/style.css +171 -1
  77. package/ui/public/team-metrics-timeline.mjs +480 -0
  78. package/ui/public/team-metrics-view.mjs +10 -2
  79. package/ui/public/team-policy-view.mjs +6 -1
  80. package/ui/server.mjs +454 -104
package/README.md CHANGED
@@ -197,6 +197,11 @@ and durations, the clarify Q&A, agent transcripts, and logs:
197
197
  another project (or a workspace's metrics home) that already records.
198
198
  - **A Team metrics page** — project and workspace scope, spend/runs/duration/autonomy/review
199
199
  KPIs, breakdowns and a CSV export.
200
+ - **A Timeline for planners** — work items as bars on a calendar (month → week → day), grouped
201
+ by work item or person: what shipped (the PR merged), what waits for review, what needs
202
+ attention. Merge dates come from an optional GitHub Action (`worca metrics pr-workflow`) or the
203
+ GitHub CLI, and the page still works without either. With the Action, pull requests made
204
+ outside Worca show too, so the calendar covers the whole team's delivery.
200
205
  - **`worca metrics push`** — flush pending run records from the CLI, e.g. on a headless machine.
201
206
 
202
207
  See [`docs/team-metrics.md`](docs/team-metrics.md).
@@ -238,7 +243,7 @@ See [`docs/team-policy.md`](docs/team-policy.md).
238
243
  in-process bridge lets the Claude Code CLI run against a Copilot subscription
239
244
  (Claude models through Copilot's native Anthropic endpoint, thinking intact;
240
245
  GPT, Gemini and the rest through a translation layer) or any
241
- `/chat/completions` endpoint — no LiteLLM, no second daemon. Sign in once on
246
+ `/chat/completions` or `/responses` endpoint — no LiteLLM, no second daemon. Sign in once on
242
247
  Settings › Models › Providers, import Copilot's models, pick them anywhere.
243
248
  See [`docs/models.md`](docs/models.md).
244
249
 
@@ -270,6 +275,31 @@ Requirements:
270
275
  `postinstall` didn't run) or the layout isn't npm's, Worca says so rather than
271
276
  a bare `ENOENT`; `WORCA_CLAUDE_BIN` can always point at a `claude.exe` directly.
272
277
 
278
+ ### In a container
279
+
280
+ Prefer the agents to run in a disposable Linux box instead of on your machine?
281
+ The same Worca ships as an image (`ghcr.io/sinishadjukic/worca`) with a Compose
282
+ file; the UI, the CLI, guardrails, plugins and your data work the same way.
283
+
284
+ ```bash
285
+ worca container up # from the npm install: writes ~/.worca-cc/container/, starts the box
286
+ worca container login # log Claude Code in, once; UI on http://localhost:4317
287
+ ```
288
+
289
+ or, without npm, download `docker/compose.yml`, set `WORCA_PROJECTS` in a
290
+ `.env` beside it and `docker compose up -d`. See [`docs/docker.md`](docs/docker.md)
291
+ for login options, git credentials, the egress allowlist, clone-in mode and
292
+ the Windows/WSL2 notes.
293
+
294
+ ### Hosted, behind Cloudflare Access
295
+
296
+ The same image runs as an always-on service: on Railway (or any host), reachable only through a
297
+ Cloudflare Tunnel with Cloudflare Access in front, and worca verifying the Access token itself.
298
+ Step by step: [`docs/deploy-railway.md`](docs/deploy-railway.md); the Cloudflare side and the
299
+ security model: [`docs/remote-access.md`](docs/remote-access.md). Upgrades, configuration and checks of a running
300
+ deployment: [Operate your deployment](docs/deploy-railway.md#operate-your-deployment), with the
301
+ `tools/railway/worca-railway.mjs` tool and the `/worca-railway` skill for Claude Code.
302
+
273
303
  ## Quick start
274
304
 
275
305
  ### Web UI
@@ -334,6 +364,9 @@ worca --project /path/to/your/project --prompt "demo task" --mock --yes
334
364
  # flush pending team-metrics run records (headless machines with no UI server)
335
365
  worca metrics push
336
366
 
367
+ # record PR merges for the Team metrics Timeline (adds a GitHub Action; commit and push it)
368
+ worca metrics pr-workflow
369
+
337
370
  # team policy: what applies to this project, fetch the branch now, meet the setup checklist
338
371
  worca policy show
339
372
  worca policy pull
@@ -387,6 +420,8 @@ The skill starts the same deterministic orchestrator.
387
420
  - [Models](docs/models.md) — the catalog, providers (GitHub Copilot, OpenAI-compatible) and the built-in bridge
388
421
  - [Getting started](docs/getting-started.md) — the in-app checklist, welcome and spotlight guides
389
422
  - [Storage](docs/storage.md) — where state lives, project keys, migration
423
+ - [Remote access](docs/remote-access.md) — opt-in, behind Cloudflare Access, with worca checking the token
424
+ - [Deploy on Railway](docs/deploy-railway.md) — the container as a hosted service behind Cloudflare Access
390
425
  - [Releasing](docs/RELEASING.md) — how `@worca/app` versions are published
391
426
  - [Contributing](CONTRIBUTING.md) — developing Worca from source
392
427
 
@@ -0,0 +1,54 @@
1
+ # docker/.env.example — copy to .env next to compose.yml. Every line is optional.
2
+ # chmod 600 .env if it holds a token.
3
+
4
+ # The folder that holds your repositories (mounted at the same path inside;
5
+ # see docs/docker.md for the Windows/WSL2 and /projects notes).
6
+ WORCA_PROJECTS=/Users/me/dev
7
+ # Mount it somewhere else inside the container (loses path parity):
8
+ #WORCA_PROJECTS_MOUNT=/projects
9
+
10
+ # Image tag: latest | rc | 1.3.0 | 1.3.0-full | dev
11
+ #WORCA_TAG=latest
12
+
13
+ # Host port the UI is published on (loopback only).
14
+ #WORCA_PORT=4317
15
+
16
+ # Scheduled runs use wall-clock time in this zone.
17
+ TZ=Europe/Berlin
18
+
19
+ # Git identity for the commits agents make in run worktrees.
20
+ GIT_AUTHOR_NAME=Your Name
21
+ GIT_AUTHOR_EMAIL=you@example.com
22
+
23
+ # GitHub over HTTPS: PRs, GitHub Issues source, team metrics/policy branches, clone-in.
24
+ #GH_TOKEN=github_pat_...
25
+
26
+ # Claude Code auth. Prefer none of these and `docker compose run --rm worca claude`
27
+ # to log in once into the volume. Otherwise ONE of:
28
+ #CLAUDE_CODE_OAUTH_TOKEN=... # from `claude setup-token` on a logged-in machine
29
+ #ANTHROPIC_API_KEY=sk-ant-... # Console key (visible to every child process)
30
+ # API key as a compose secret instead (never in the environment):
31
+ # put the key in ./anthropic_api_key, then add to a compose.override.yml:
32
+ # services: { worca: { secrets: [anthropic_api_key] } }
33
+ # secrets: { anthropic_api_key: { file: ./anthropic_api_key } }
34
+
35
+ # Linux Engine only: your uid/gid when not 1000 (`id -u`, `id -g`).
36
+ #WORCA_UID=1000
37
+ #WORCA_GID=1000
38
+
39
+ # Resource caps.
40
+ #WORCA_MEM=6g
41
+ #WORCA_CPUS=4
42
+
43
+ # Corporate proxy / TLS interception.
44
+ #HTTPS_PROXY=http://proxy.corp:3128
45
+ #NO_PROXY=127.0.0.1,localhost
46
+
47
+ # Egress overlay (compose.egress.yml): allowlist, ".host" allows subdomains.
48
+ #WORCA_EGRESS_ALLOW=api.anthropic.com,github.com,.github.com,.githubusercontent.com,registry.npmjs.org
49
+
50
+ # SSH overlay (compose.ssh.yml), Linux Engine / Podman only.
51
+ #WORCA_SSH_SOCK=/run/user/1000/keyring/ssh
52
+
53
+ # Teams overlay (compose.teams.yml).
54
+ #CLOUDFLARE_TUNNEL_TOKEN=...
@@ -0,0 +1,24 @@
1
+ # docker/compose.clonein.yml — clone-in mode: nothing from the host is mounted
2
+ # (docs/docker.md, "Clone-in mode").
3
+ #
4
+ # docker compose -f compose.yml -f compose.clonein.yml up -d
5
+ # docker compose -f compose.yml -f compose.clonein.yml run --rm worca \
6
+ # git clone https://github.com/acme/api.git /projects/api
7
+ # docker compose -f compose.yml -f compose.clonein.yml run --rm worca worca add --path /projects/api
8
+ #
9
+ # /projects becomes a named volume. Results leave the container only as pushed
10
+ # branches and PRs, so git credentials (GH_TOKEN or the ssh overlay) are needed.
11
+ # `!override` REPLACES the base file's volume list (compose merges lists by
12
+ # target otherwise, which would keep the host bind mount).
13
+
14
+ services:
15
+ worca:
16
+ volumes: !override
17
+ - worca-home:/worca
18
+ - claude-config:/home/worca/.claude
19
+ - projects:/projects
20
+ environment:
21
+ WORCA_PROJECTS_ROOT: /projects
22
+
23
+ volumes:
24
+ projects:
@@ -0,0 +1,24 @@
1
+ # docker/compose.dev.yml — hack on Worca itself inside the container (CONTRIBUTING.md).
2
+ #
3
+ # cd docker
4
+ # docker compose -f compose.yml -f compose.dev.yml up # server from ../ (this checkout)
5
+ # docker compose -f compose.yml -f compose.dev.yml run --rm worca npm test
6
+ #
7
+ # Bind-mounts the repository checkout over /app and runs the server from it,
8
+ # so edits on the host are live in the container's Linux `claude`. node_modules
9
+ # is a container-side tmpfs owned by the worca user (a named volume at a path
10
+ # the image does not have would be created root-owned): the host's modules stay
11
+ # untouched, and `npm ci` (about a second) runs on every start.
12
+
13
+ services:
14
+ worca:
15
+ image: ghcr.io/sinishadjukic/worca:${WORCA_TAG:-dev}
16
+ working_dir: /app
17
+ volumes:
18
+ - ..:/app
19
+ tmpfs:
20
+ - /tmp:size=2g
21
+ - /app/node_modules:size=1g,uid=1000,gid=1000
22
+ environment:
23
+ WORCA_HOME: /worca
24
+ command: ["bash", "-c", "npm ci --no-audit --no-fund && exec node --disable-warning=ExperimentalWarning ui/server.mjs"]
@@ -0,0 +1,47 @@
1
+ # docker/compose.egress.yml — egress allowlist overlay (docs/docker.md, "Egress allowlist").
2
+ #
3
+ # docker compose -f compose.yml -f compose.egress.yml up -d
4
+ #
5
+ # Puts worca on an INTERNAL network (no route to the internet) and adds a
6
+ # forward proxy from the same image that relays only to allowlisted hosts.
7
+ # Tools that honour HTTPS_PROXY (claude, git, gh, npm, pip, curl) work for
8
+ # allowed hosts; a raw socket from `node -e` or `python -c` cannot leave.
9
+ #
10
+ # WORCA_EGRESS_ALLOW comma-separated hosts; ".example.com" allows subdomains.
11
+ # Default: api.anthropic.com, github.com, .github.com,
12
+ # .githubusercontent.com, registry.npmjs.org
13
+
14
+ services:
15
+ worca:
16
+ depends_on:
17
+ - egress
18
+ networks:
19
+ - internal
20
+ environment:
21
+ HTTPS_PROXY: http://egress:3128
22
+ HTTP_PROXY: http://egress:3128
23
+ NO_PROXY: 127.0.0.1,localhost
24
+ # Playwright downloads Chromium from a CDN; add it to the allowlist or
25
+ # pre-fetch it. Listed here so the failure is legible, not silent.
26
+ PLAYWRIGHT_DOWNLOAD_HOST: ${PLAYWRIGHT_DOWNLOAD_HOST:-}
27
+
28
+ egress:
29
+ image: ghcr.io/sinishadjukic/worca:${WORCA_TAG:-latest}
30
+ entrypoint: ["tini", "-s", "--", "node", "/usr/local/lib/worca-egress-proxy.mjs"]
31
+ command: []
32
+ environment:
33
+ WORCA_EGRESS_ALLOW: ${WORCA_EGRESS_ALLOW:-}
34
+ networks:
35
+ - internal
36
+ - default
37
+ security_opt:
38
+ - no-new-privileges:true
39
+ cap_drop:
40
+ - ALL
41
+ pids_limit: 128
42
+ mem_limit: 256m
43
+ restart: unless-stopped
44
+
45
+ networks:
46
+ internal:
47
+ internal: true
@@ -0,0 +1,17 @@
1
+ # docker/compose.ssh.yml — forward the host's SSH agent (docs/docker.md, "Git credentials").
2
+ #
3
+ # docker compose -f compose.yml -f compose.ssh.yml up -d
4
+ #
5
+ # Keys never enter the container; the agent socket does. While it is mounted an
6
+ # agent CAN use it, so pair this with the egress overlay for untrusted tasks.
7
+ #
8
+ # Docker Desktop (macOS/Windows): the socket is /run/host-services/ssh-auth.sock
9
+ # inside the VM — leave WORCA_SSH_SOCK unset.
10
+ # Linux Engine / Podman: WORCA_SSH_SOCK=$SSH_AUTH_SOCK in .env
11
+
12
+ services:
13
+ worca:
14
+ volumes:
15
+ - ${WORCA_SSH_SOCK:-/run/host-services/ssh-auth.sock}:/ssh-agent.sock
16
+ environment:
17
+ SSH_AUTH_SOCK: /ssh-agent.sock
@@ -0,0 +1,25 @@
1
+ # docker/compose.teams.yml — Microsoft Teams webhook ingress (docs/docker.md, "Teams").
2
+ #
3
+ # docker compose -f compose.yml -f compose.teams.yml up -d
4
+ #
5
+ # Teams is the one chat channel that needs an inbound HTTPS URL (Bot Framework
6
+ # has no polling mode). A cloudflared sidecar dials OUT to Cloudflare and
7
+ # forwards to worca's token-guarded ingress route; nothing is published.
8
+ #
9
+ # CLOUDFLARE_TUNNEL_TOKEN a named tunnel's token (recommended; stable URL)
10
+ # unset: a Quick Tunnel with a random trycloudflare.com
11
+ # URL printed in `docker compose logs teams-tunnel`
12
+
13
+ services:
14
+ teams-tunnel:
15
+ image: cloudflare/cloudflared:latest
16
+ command: >
17
+ tunnel --no-autoupdate
18
+ ${CLOUDFLARE_TUNNEL_TOKEN:+run --token }${CLOUDFLARE_TUNNEL_TOKEN:---url http://worca:4317}
19
+ depends_on:
20
+ - worca
21
+ security_opt:
22
+ - no-new-privileges:true
23
+ cap_drop:
24
+ - ALL
25
+ restart: unless-stopped
@@ -0,0 +1,57 @@
1
+ # docker/compose.yml — run Worca in a container (docs/docker.md).
2
+ #
3
+ # mkdir worca && cd worca
4
+ # curl -fsSLO https://raw.githubusercontent.com/SinishaDjukic/worca-cc/dev/docker/compose.yml
5
+ # echo "WORCA_PROJECTS=$HOME/dev" > .env
6
+ # docker compose up -d # http://localhost:4317
7
+ # docker compose run --rm worca claude # log Claude Code in, once
8
+ #
9
+ # Everything you change is a variable in .env (see docker/.env.example); this
10
+ # file is not edited. Overlays (egress allowlist, SSH agent, Teams tunnel,
11
+ # clone-in, dev) are separate files added with -f.
12
+
13
+ name: worca
14
+
15
+ services:
16
+ worca:
17
+ image: ghcr.io/sinishadjukic/worca:${WORCA_TAG:-latest}
18
+ init: true
19
+ user: "${WORCA_UID:-1000}:${WORCA_GID:-1000}"
20
+ ports:
21
+ # Loopback only. The server's Host/Origin guard accepts localhost, and
22
+ # nothing else should reach an unauthenticated UI that runs agents.
23
+ - "127.0.0.1:${WORCA_PORT:-4317}:4317"
24
+ volumes:
25
+ - worca-home:/worca # DB, store, runs, plugins, ui.json
26
+ - claude-config:/home/worca/.claude # Claude Code login, settings, memory
27
+ # Your repositories. Same path on both sides (path parity) so git
28
+ # worktree metadata stays valid on the host; see docs/docker.md.
29
+ - ${WORCA_PROJECTS:-${HOME}/dev}:${WORCA_PROJECTS_MOUNT:-${WORCA_PROJECTS:-${HOME}/dev}}
30
+ environment:
31
+ TZ: ${TZ:-UTC}
32
+ WORCA_PROJECTS_ROOT: ${WORCA_PROJECTS_MOUNT:-${WORCA_PROJECTS:-${HOME}/dev}}
33
+ GIT_AUTHOR_NAME: ${GIT_AUTHOR_NAME:-}
34
+ GIT_AUTHOR_EMAIL: ${GIT_AUTHOR_EMAIL:-}
35
+ GIT_COMMITTER_NAME: ${GIT_AUTHOR_NAME:-}
36
+ GIT_COMMITTER_EMAIL: ${GIT_AUTHOR_EMAIL:-}
37
+ GH_TOKEN: ${GH_TOKEN:-}
38
+ CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-}
39
+ ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
40
+ HTTPS_PROXY: ${HTTPS_PROXY:-}
41
+ HTTP_PROXY: ${HTTP_PROXY:-}
42
+ NO_PROXY: ${NO_PROXY:-127.0.0.1,localhost}
43
+ WORCA_MOCK: ${WORCA_MOCK:-}
44
+ security_opt:
45
+ - no-new-privileges:true
46
+ cap_drop:
47
+ - ALL
48
+ pids_limit: 2048
49
+ mem_limit: ${WORCA_MEM:-6g}
50
+ cpus: ${WORCA_CPUS:-4}
51
+ tmpfs:
52
+ - /tmp:size=2g
53
+ restart: unless-stopped
54
+
55
+ volumes:
56
+ worca-home:
57
+ claude-config:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/app",
3
- "version": "1.4.0-rc.1",
3
+ "version": "1.5.0-rc.2",
4
4
  "description": "Worca — deterministic multi-agent pipeline that drives Claude Code (headless) through Plan -> Refine -> Implement -> Review, with a CLI, an installable /worca skill, and a web UI.",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -38,6 +38,8 @@
38
38
  "skills/",
39
39
  "scripts/",
40
40
  "tools/install.mjs",
41
+ "docker/compose*.yml",
42
+ "docker/.env.example",
41
43
  "README.md"
42
44
  ],
43
45
  "scripts": {
@@ -49,6 +51,7 @@
49
51
  "smoke:workspace": "WORCA_MOCK=1 WORCA_HOME=.worca-cc-smoke node --disable-warning=ExperimentalWarning tools/smoke-workspace.mjs",
50
52
  "smoke:plugin": "WORCA_MOCK=1 WORCA_HOME=.worca-cc-smoke node --disable-warning=ExperimentalWarning tools/smoke-plugin.mjs",
51
53
  "docker:build": "node tools/docker-build.mjs",
54
+ "docker:smoke": "node tools/docker-smoke.mjs",
52
55
  "ask:fixtures": "node --disable-warning=ExperimentalWarning tools/ask-capture-fixtures.mjs",
53
56
  "verify:composer": "node --disable-warning=ExperimentalWarning tools/verify-composer-cdp.mjs",
54
57
  "verify:run-monitor": "node --disable-warning=ExperimentalWarning tools/verify-run-monitor-cdp.mjs",
@@ -0,0 +1,227 @@
1
+ // src/cli/container.mjs
2
+ // `worca container` — run Worca in a container from the npm install
3
+ // (docs/docker.md; plans/container-isolation-design.md §12.3). A thin wrapper:
4
+ // it writes the package's compose files into <worcaHome>/container/, seeds a
5
+ // .env once, and shells out to `docker compose` (or `podman compose`). Nothing
6
+ // here talks to the engine; the box runs its own Worca with its own home.
7
+ //
8
+ // Every verb takes { out, c, fail } from the CLI and an injectable `exec` so
9
+ // tests never spawn a runtime.
10
+
11
+ import { spawnSync } from 'node:child_process';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, join, resolve } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import process from 'node:process';
17
+
18
+ import { worcaHome, listProjects } from '../core/projects.mjs';
19
+
20
+ /** The compose files shipped in the package (package.json `files`). */
21
+ export const COMPOSE_SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'docker');
22
+ export const OVERLAYS = Object.freeze(['egress', 'ssh', 'teams', 'clonein']);
23
+ const STATE_FILE = '.worca-container.json';
24
+
25
+ export const CONTAINER_HELP = `worca container — run Worca in a container (docs/docker.md)
26
+
27
+ worca container init [--projects <dir>] Write the compose files and a .env into the container dir
28
+ worca container up [--with <overlays>] [--tag <t>]
29
+ Start the box in the background and print the URL.
30
+ --with: comma-separated egress,ssh,teams,clonein (remembered)
31
+ worca container down Stop and remove the containers (volumes are kept)
32
+ worca container status Which containers are up
33
+ worca container logs [--follow] Server logs
34
+ worca container pull Pull the newest image for the tag in .env
35
+ worca container login Log Claude Code in, once, into the box's own volume
36
+ worca container shell A bash shell inside the box
37
+ worca container run -- <worca args> Run the Worca CLI inside the box
38
+ e.g. worca container run -- --project /path/to/repo --prompt "…"
39
+ worca container where Print the container dir (compose files, .env)
40
+ worca container help
41
+
42
+ Options:
43
+ --dir <d> Container dir (default: <worcaHome>/container)
44
+ --runtime <r> docker | podman (default: autodetect; env WORCA_CONTAINER_RUNTIME)
45
+
46
+ The box has its own Worca home and its own Claude Code login: nothing from
47
+ ~/.worca-cc or ~/.claude is mounted. Your repositories are mounted at the same
48
+ path as on the host (WORCA_PROJECTS in .env), so run worktrees stay valid here.
49
+ `;
50
+
51
+ /** Default exec: run and inherit the terminal. Returns { status }. */
52
+ function defaultExec(cmd, args, { cwd, capture = false, env } = {}) {
53
+ const r = spawnSync(cmd, args, { cwd, env, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: 'utf8' });
54
+ return { status: r.error ? 127 : (r.status ?? 1), stdout: r.stdout || '', stderr: r.stderr || '' };
55
+ }
56
+
57
+ /** `docker compose` or `podman compose`, by flag, env, then autodetect. */
58
+ export function detectRuntime(exec, want) {
59
+ const pick = want || process.env.WORCA_CONTAINER_RUNTIME || '';
60
+ const candidates = pick ? [pick] : ['docker', 'podman'];
61
+ for (const bin of candidates) {
62
+ if (!['docker', 'podman'].includes(bin)) return { error: `--runtime must be docker or podman, got ${bin}` };
63
+ if (exec(bin, ['compose', 'version'], { capture: true }).status === 0) return { bin };
64
+ }
65
+ return { error: pick ? `${pick} compose is not available` : 'no container runtime found: install Docker Desktop, Docker Engine or Podman (with podman compose)' };
66
+ }
67
+
68
+ function parse(argv) {
69
+ const o = { verb: argv[0] || 'help', dir: null, runtime: null, projects: null, with: null, tag: null, follow: false, passthrough: [], _: [] };
70
+ const rest = argv.slice(1);
71
+ for (let i = 0; i < rest.length; i++) {
72
+ const a = rest[i];
73
+ if (a === '--') { o.passthrough = rest.slice(i + 1); break; }
74
+ else if (a === '--dir') o.dir = rest[++i];
75
+ else if (a === '--runtime') o.runtime = rest[++i];
76
+ else if (a === '--projects') o.projects = rest[++i];
77
+ else if (a === '--with') o.with = rest[++i];
78
+ else if (a === '--tag') o.tag = rest[++i];
79
+ else if (a === '--follow' || a === '-f') o.follow = true;
80
+ else o._.push(a);
81
+ }
82
+ return o;
83
+ }
84
+
85
+ /** Longest common ancestor directory of the registered projects, or null. */
86
+ export function commonParent(paths) {
87
+ const parts = paths.filter(Boolean).map((p) => resolve(p).split(/[\\/]+/));
88
+ if (!parts.length) return null;
89
+ let prefix = parts[0].slice(0, -1); // a project's parent, never the project itself
90
+ for (const p of parts.slice(1)) {
91
+ let n = 0;
92
+ while (n < prefix.length && n < p.length - 1 && prefix[n] === p[n]) n++;
93
+ prefix = prefix.slice(0, n);
94
+ }
95
+ if (prefix.length <= 1) return null; // "/" or a drive root is too wide to mount
96
+ return prefix.join('/') || '/';
97
+ }
98
+
99
+ /** The .env body for a fresh container dir: the example with the known values filled in. */
100
+ export function seedEnv(example, { projects, tz, gitName, gitEmail }) {
101
+ const set = (body, key, value) => {
102
+ if (!value) return body;
103
+ const re = new RegExp(`^#?${key}=.*$`, 'm');
104
+ return re.test(body) ? body.replace(re, `${key}=${value}`) : `${body}\n${key}=${value}\n`;
105
+ };
106
+ let body = example;
107
+ body = set(body, 'WORCA_PROJECTS', projects);
108
+ body = set(body, 'TZ', tz);
109
+ body = set(body, 'GIT_AUTHOR_NAME', gitName);
110
+ body = set(body, 'GIT_AUTHOR_EMAIL', gitEmail);
111
+ return body;
112
+ }
113
+
114
+ function readState(dir) {
115
+ try { return JSON.parse(readFileSync(join(dir, STATE_FILE), 'utf8')); } catch { return {}; }
116
+ }
117
+ function writeState(dir, state) {
118
+ writeFileSync(join(dir, STATE_FILE), JSON.stringify(state, null, 2) + '\n');
119
+ }
120
+
121
+ /** `-f compose.yml -f compose.<overlay>.yml …` for the remembered or requested overlays. */
122
+ export function composeFileArgs(overlays) {
123
+ const args = ['-f', 'compose.yml'];
124
+ for (const o of overlays) args.push('-f', `compose.${o}.yml`);
125
+ return args;
126
+ }
127
+
128
+ function parseWith(s) {
129
+ if (!s) return [];
130
+ const list = s.split(',').map((x) => x.trim()).filter(Boolean);
131
+ const bad = list.filter((x) => !OVERLAYS.includes(x));
132
+ if (bad.length) return { error: `--with: unknown overlay ${bad.join(', ')} (known: ${OVERLAYS.join(', ')})` };
133
+ return [...new Set(list)];
134
+ }
135
+
136
+ /** Copy the package's compose files into `dir` (always, they are versioned with the package)
137
+ * and seed .env once. Returns { dir, envCreated }. */
138
+ export async function initDir(dir, { projects, exec, srcDir = COMPOSE_SRC_DIR, env = process.env } = {}) {
139
+ mkdirSync(dir, { recursive: true });
140
+ for (const f of readdirSync(srcDir)) {
141
+ if (/^compose(\.[a-z]+)?\.yml$/.test(f)) copyFileSync(join(srcDir, f), join(dir, f));
142
+ }
143
+ const envPath = join(dir, '.env');
144
+ let envCreated = false;
145
+ if (!existsSync(envPath)) {
146
+ let root = projects;
147
+ if (!root) {
148
+ try { root = commonParent((await listProjects()).map((p) => p.path)); } catch { root = null; }
149
+ }
150
+ if (!root) root = join(env.HOME || env.USERPROFILE || homedir(), 'dev');
151
+ const git = (k) => { const r = exec('git', ['config', '--get', k], { capture: true }); return r.status === 0 ? r.stdout.trim() : ''; };
152
+ const body = seedEnv(readFileSync(join(srcDir, '.env.example'), 'utf8'), {
153
+ projects: root,
154
+ tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
155
+ gitName: git('user.name'),
156
+ gitEmail: git('user.email'),
157
+ });
158
+ writeFileSync(envPath, body, { mode: 0o600 });
159
+ envCreated = true;
160
+ }
161
+ return { dir, envCreated };
162
+ }
163
+
164
+ function readEnvValue(dir, key) {
165
+ try {
166
+ const m = new RegExp(`^${key}=(.*)$`, 'm').exec(readFileSync(join(dir, '.env'), 'utf8'));
167
+ return m ? m[1].trim() : '';
168
+ } catch { return ''; }
169
+ }
170
+
171
+ /**
172
+ * `worca container <verb> …`. Returns the process exit code.
173
+ * @param {string[]} argv tokens after `container`
174
+ * @param {object} io { out, c, fail } from the CLI; `exec` and `dir` are injectable for tests
175
+ */
176
+ export async function cmdContainer(argv, { out, c, fail, exec = defaultExec, dir: dirOverride = null } = {}) {
177
+ const a = parse(argv);
178
+ if (a.verb === 'help' || a.verb === '--help' || a.verb === '-h') { out(CONTAINER_HELP.trimEnd()); return 0; }
179
+ const dir = resolve(a.dir || dirOverride || join(worcaHome(), 'container'));
180
+ if (a.verb === 'where') { out(dir); return 0; }
181
+
182
+ const rt = detectRuntime(exec, a.runtime);
183
+ if (rt.error) { fail(rt.error); return 1; }
184
+ const state = readState(dir);
185
+ let overlays = Array.isArray(state.with) ? state.with : [];
186
+ const compose = (args, opts = {}) => exec(rt.bin, ['compose', ...composeFileArgs(overlays), ...args], { cwd: dir, ...opts }).status;
187
+
188
+ if (a.verb === 'init' || a.verb === 'up') {
189
+ const r = await initDir(dir, { projects: a.projects, exec });
190
+ out(`${c('bold', 'container dir')} ${dir}${r.envCreated ? ` (${c('green', '.env created')} — edit it: WORCA_PROJECTS, tokens)` : ''}`);
191
+ if (a.verb === 'init') return 0;
192
+ }
193
+ if (!existsSync(join(dir, 'compose.yml'))) { fail(`no compose files in ${dir}; run: worca container init`); return 1; }
194
+
195
+ switch (a.verb) {
196
+ case 'up': {
197
+ if (a.with !== null) {
198
+ const parsed = parseWith(a.with);
199
+ if (parsed.error) { fail(parsed.error); return 1; }
200
+ overlays = parsed;
201
+ writeState(dir, { ...state, with: overlays });
202
+ }
203
+ const env = a.tag ? { ...process.env, WORCA_TAG: a.tag } : undefined;
204
+ const port = readEnvValue(dir, 'WORCA_PORT') || '4317';
205
+ const code = exec(rt.bin, ['compose', ...composeFileArgs(overlays), 'up', '-d'], { cwd: dir, env }).status;
206
+ if (code !== 0) return code;
207
+ out(`${c('bold', 'Worca')} http://localhost:${port}${overlays.length ? ` overlays: ${overlays.join(', ')}` : ''}`);
208
+ out(` Log in once: ${c('bold', 'worca container login')}`);
209
+ out(` Stop: ${c('bold', 'worca container down')}`);
210
+ return 0;
211
+ }
212
+ case 'down': return compose(['down']);
213
+ case 'status': return compose(['ps']);
214
+ case 'logs': return compose(['logs', ...(a.follow ? ['-f'] : []), 'worca']);
215
+ case 'pull': return compose(['pull', 'worca']);
216
+ case 'login': return compose(['run', '--rm', 'worca', 'claude']);
217
+ case 'shell': return compose(['run', '--rm', 'worca', 'bash']);
218
+ case 'run': {
219
+ const args = a.passthrough.length ? a.passthrough : a._;
220
+ if (!args.length) { fail('Usage: worca container run -- <worca args>'); return 1; }
221
+ return compose(['run', '--rm', 'worca', 'worca', ...args]);
222
+ }
223
+ default:
224
+ fail(`unknown container verb: ${a.verb}\n\n${CONTAINER_HELP}`);
225
+ return 1;
226
+ }
227
+ }
@@ -13,6 +13,7 @@ import {
13
13
  endpointModelsForImport, importEndpointModels,
14
14
  } from '../core/bridge/provider-ops.mjs';
15
15
  import { listModels } from '../core/config.mjs';
16
+ import { isTranslatedApi } from '../core/model-env.mjs';
16
17
 
17
18
  export const MODELS_HELP = `worca models — the model catalog and its providers
18
19
 
@@ -76,7 +77,7 @@ export function formatModelLine(m) {
76
77
  else if (m.custom === 'policy') bits.push('policy');
77
78
  else if (m.custom === 'global') bits.push('yours');
78
79
  else if (!m.custom) bits.push('built-in');
79
- if (m.bridged) bits.push(`bridged: ${m.bridged}${m.upstreamModel ? ` → ${m.upstreamModel}` : ''}${m.upstreamApi === 'openai-chat' ? ' (translated)' : ''}`);
80
+ if (m.bridged) bits.push(`bridged: ${m.bridged}${m.upstreamModel ? ` → ${m.upstreamModel}` : ''}${isTranslatedApi(m.upstreamApi) ? ' (translated)' : ''}`);
80
81
  else if (m.routed) bits.push('endpoint-routed');
81
82
  if (m.needsSignIn) bits.push(`NEEDS ${m.signInReason === 'no_key' ? 'API KEY' : m.signInReason === 'terms' ? 'ACKNOWLEDGEMENT' : 'SIGN-IN'}`);
82
83
  if (m.hidden) bits.push('hidden');
@@ -208,14 +209,14 @@ export async function cmdModels(argv, { out, c, fail, sleep: wait = sleep }) {
208
209
  else if (args.all) ids = importable.map((m) => m.id);
209
210
  else {
210
211
  out('Copilot models (pass --all, or --pick id,id,…):');
211
- for (const m of list) out(` ${m.id.padEnd(28)} ${m.vendor.padEnd(10)} ${(m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k` : '-').padStart(5)} ${m.toolCalls ? 'tools' : ' '} ${m.vision ? 'vision' : ' '} ${m.reasoning ? 'reasoning' : ' '}${m.inCatalog ? ' (in catalog)' : ''}${m.policyState && m.policyState !== 'enabled' ? ' (disabled in Copilot settings)' : ''}`);
212
+ for (const m of list) out(` ${m.id.padEnd(28)} ${m.vendor.padEnd(10)} ${(m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k` : '-').padStart(5)} ${m.toolCalls ? 'tools' : ' '} ${m.vision ? 'vision' : ' '} ${m.reasoning ? 'reasoning' : ' '} ${String(m.api || '').padEnd(16)}${m.inCatalog ? ' (in catalog)' : ''}${m.policyState && m.policyState !== 'enabled' ? ' (disabled in Copilot settings)' : ''}`);
212
213
  return 0;
213
214
  }
214
- out('These run through your Copilot subscription. Claude models keep extended thinking; other vendors run through a translation layer (no thinking blocks, no web tools).');
215
+ out('These run through your Copilot subscription. Claude models keep extended thinking; other vendors run through a translation layer (no web tools; reasoning arrives as summaries on the Responses API).');
215
216
  if (!(await confirm(`Import ${ids.length} model${ids.length === 1 ? '' : 's'} into the catalog?`, args.yes, { c }))) return fail('cancelled');
216
217
  const r = await importCopilotModels(ids);
217
218
  for (const id of r.created) out(` + ${id}`);
218
- for (const id of r.updated) out(` ~ ${id} (capabilities refreshed)`);
219
+ for (const id of r.updated) out(` ~ ${id} (API and capabilities refreshed)`);
219
220
  for (const id of r.skipped) out(` - ${id} (skipped: not offered, or not a copilot entry)`);
220
221
  return 0;
221
222
  }