Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- name: Focused tests, leaving nothing in the temp location
run: node scripts/test-temp-guard.mjs test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/selfhost-modal.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
run: node scripts/test-temp-guard.mjs test/cloud-lite.test.mjs test/cloud-lite-claude.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/selfhost-modal.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
- name: Self-hosted model settings (Python, offline)
run: python3 -B -m unittest discover -s selfhost/modal -p "test_*.py"
- name: Install and exercise the packed public package
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ For FACTORY, match `.github/workflows/public-sdk.yml` on both platforms: `npm
ci`, production dependency audit, the focused public SDK tests, a fresh install
of `npm pack` output, swarm creation, CLI help, and installed MCP stdio smoke.
Run the supported Node 22.17 and Node 24 lines. On this Windows host, use an
exact supported Node binary; for Linux, a disposable local Docker container
exact supported Node binary and put Windows' `tar.exe` ahead of Git's on the
PATH (Git's reads `C:` as a remote host); for Linux, a disposable local Docker container
with a tracked-source archive avoids platform-mixed `node_modules` and local
runtime files.

Expand Down
80 changes: 70 additions & 10 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Public local Factory SDK, CLI, and MCP

Version 0.5.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.5.0):
Version 0.6.0 is distributed as a versioned package in
[GitHub Releases](https://github.com/flujo-app/FACTORY/releases/tag/v0.6.0):

```sh
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.5.0/flujo-factory-0.5.0.tgz
npm install https://github.com/flujo-app/FACTORY/releases/download/v0.6.0/flujo-factory-0.6.0.tgz
```

FACTORY can serve an open-weight model on your own Modal GPUs and run workers on it.
`ModalSelfHostedModel` and `factory-selfhost` deploy, verify, stop and retire a vLLM endpoint
with Qwen3.8-27B (H100) or Nemotron 3.5 Super (B300) profiles; see
[selfhost/README.md](selfhost/README.md). Its endpoint description is a model provider that
cloud lite workers (Codex) and local lite workers (Codex or Claude Code) use directly. See
cloud and local lite workers (Codex or Claude Code) use directly. See
[Self-hosted models and operator-driven jobs](#self-hosted-models-and-operator-driven-jobs)
for the provider, read-only deploy keys, prepare hooks, operator-driven jobs and quieter
traces added in 0.4.0. All of it is optional; existing plans behave as before, except that
Expand Down Expand Up @@ -203,11 +203,13 @@ reads the key from the variable the file names in `envKey`.

## FLUJO worker dispatch

### Cloud Codex lite workers (development)
### Cloud lite workers (development)

`FactoryCloudLiteFleet` provisions a dedicated Fly fleet running native Codex
app-server harnesses with their built-in tools and only FACTORY's board MCP.
Each machine has one orchestrator and `agentsPerMachine - 1` subordinate Codex
app-server harnesses (or Claude Code, see
[Claude Code as the worker program](#claude-code-as-the-worker-program)) with their
built-in tools and only FACTORY's board MCP.
Each machine has one orchestrator and `agentsPerMachine - 1` subordinate
sessions, isolated Git worktrees, persisted native histories, and durable traces.
The SDK starts and registers the configured pool; additional native automatic
fanout is disabled so every subordinate has its own board identity and policy.
Expand Down Expand Up @@ -260,6 +262,64 @@ while workers execute; a disconnected host can prevent authentication renewal.
The runtime does not switch to an API key. Credentials never belong in an image,
public journal, source commit or trace. Store the fleet directory privately.

#### Claude Code as the worker program

Set `workerProgram: 'claude'` in the plan (the default stays `'codex'`). Everything
else works the same way: the board, operator commands and stop receipts, durable
traces, prepare hooks, evidence collection and verified workflows.

```js
import { FactoryCloudLiteFleet, ClaudeSubscriptionBroker } from 'flujo-factory';

// On a subscription: a token from `claude setup-token`, in a private file on this host.
const fleet = new FactoryCloudLiteFleet({ directory: '/absolute/private/fleet-run',
flujoCloudPath: '/absolute/installed/flujo-cloud',
auth: new ClaudeSubscriptionBroker({ tokenFile: '/absolute/private/claude-token' }) });
await fleet.deploy({ runId: 'my-claude-run', app: 'my-claude-app', goal: ownerGoal,
workerProgram: 'claude', model: 'opus', subagentModel: 'sonnet',
machines: 1, agentsPerMachine: 4 }, { image: immutableWorkerImage, bundlePath: '/absolute/private/flujo-factory.tgz' });

// Or on a self-hosted model: the same description Codex uses.
const provider = model.endpoint();
await selfHostedFleet.deploy({ ...plan, workerProgram: 'claude', model: provider.model, subagentModel: provider.model, modelProvider: provider },
{ image: immutableWorkerImage, bundlePath: '/absolute/private/flujo-factory.tgz', providerKey: await model.key() });
```

- **Install:** the bootstrap installs one pinned Claude Code release, checked against
its SHA-256, plus `bubblewrap` and `socat` for its sandbox. A custom image must
provide `claude` on the path, and `/bin/bash`.
- **Sessions:** each agent slot keeps one session, with history in
`/data/claude/projects`. Each turn is one Claude Code process, so an idle agent uses
no memory beyond its files. A message during a turn joins that turn. A stop
interrupts it; if the program does not answer within 10 seconds its process is
ended. A stop receipt is issued only after the process has gone and the session
reads back as idle.
- **Plans:** a plan without `modelProvider` needs a broker made for its program, and a
provider must offer the program's API: `anthropic` for Claude Code, `responses` for
Codex. The models default to `opus` and `sonnet`; with a provider, name the served model.
- **Subscription:** the token from `claude setup-token` is long-lived, and this host
holds no refresh token for it. To rotate it, replace the file; when a worker reports a
refused token, `fleet.observe()` passes on the file's current token for the next turn.
- **Keys and agent commands:** the key or token reaches only the Claude Code process.
`CLAUDE_CODE_SHELL_PREFIX` removes it from the environment of agent commands and the
board MCP server; with a provider the GitHub token goes too, as with Codex. Journals
and traces do not contain it. With `danger-full-access`, an agent command runs as the
same user and could still read it from the parent process, so use a narrower sandbox
when agents handle untrusted input. Claude Code's own `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`
is not used, because it also forces interactive permission checks on these sessions.
- **Sandbox:** `danger-full-access` skips permission checks; the Machine is the
sandbox. `workspace-write` runs commands in Claude Code's bubblewrap sandbox, writing
only to the agent's folder and `writableRoots`, with no network, and its file tools
stay in the same folders. `read-only` also blocks writes to the agent's folder and
removes the file-writing tools. Both narrow modes refuse to run commands without
the sandbox, so the worker image must allow bubblewrap. Network tools, automatic
subagents, schedules, background tasks, skills, auto memory and repository settings
files are off in every mode.
- **Traces:** assistant text, thinking, tool calls and results become `item/completed`
records (`agentMessage`, `reasoning`, `toolCall`, `toolResult`), token use becomes
`thread/tokenUsage/updated`, and model retries become `model/retry`. Streaming deltas
are skipped unless `traceDeltas` is on.

#### Self-hosted models and operator-driven jobs

To run the model yourself, `ModalSelfHostedModel` (CLI `factory-selfhost`) deploys a
Expand Down Expand Up @@ -303,11 +363,11 @@ await fleet.command('m0-a1', { id: 'job-1', action: 'message', message: 'First j
`streamMaxRetries`, `streamIdleTimeoutMs` and `requestTimeoutMs` are optional.
`codexProviderConfig`, `codexProviderOverrides` and `claudeProviderEnvironment`
are exported mappings; `providerPrograms(provider)` says which programs can use
it. Cloud lite workers run Codex. Each Codex gets a `config.toml` with the provider, the model's real context
it. Each Codex gets a `config.toml` with the provider, the model's real context
window and its compaction point; web search, multi-agent, apps, plugins, goals,
memories and skill instructions are off, and agents' shell commands inherit only
the core environment. The key is a staged Fly app secret placed only in the
Codex process environment; agent commands, journals and traces do not get it.
worker program's process environment; agent commands, journals and traces do not get it.
Machine *i* uses `baseUrls[i % n]`, so each machine keeps its prompt cache on one
server. Codex offers no `apply_patch` tool to an unknown model, so ask agents to
edit files with shell commands.
Expand All @@ -320,7 +380,7 @@ await fleet.command('m0-a1', { id: 'job-1', action: 'message', message: 'First j
turn, follow-up or wake; every turn comes from a durable
`fleet.command(agent, { id, action: 'message' | 'stop' })`. The default,
`'autonomous'`, keeps the existing goal-driven behavior.
- **`sandbox`**: Codex's sandbox for agent commands. The default stays
- **`sandbox`**: the sandbox for agent commands. The default stays
`danger-full-access`. `workspace-write` lets commands write only the agent's
folder plus the prepare hook's `writableRoots`, with no network; `read-only` is
also accepted.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flujo-factory",
"version": "0.5.0",
"version": "0.6.0",
"description": "Durable FLUJO swarm engine and Factory SDK, CLI, and MCP server",
"license": "MIT",
"repository": {
Expand Down
1 change: 1 addition & 0 deletions scripts/verify-public-sdk.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ for (const name of [
'FactoryArtifactStore',
'FactoryWorkflowSupervisor',
'captureGitSource',
'ClaudeSubscriptionBroker',
'captureWorkspace',
'submitWorkspaceCandidate',
'ContinuousEvidenceCollector',
Expand Down
3 changes: 2 additions & 1 deletion selfhost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
FACTORY can serve an open-weight model on your own Modal GPUs with vLLM, so workers run on it
instead of a vendor API: source code and prompts stay in your account. The served endpoint
speaks the OpenAI Responses API (Codex), the Anthropic Messages API (Claude Code) and Chat
Completions, and plugs straight into `plan.modelProvider` and the local lite workers.
Completions, and plugs straight into `plan.modelProvider` and the local lite workers. Cloud lite workers can
run either program on it (`plan.workerProgram`).

This folder is separate from `modal/`, which holds the earlier pilot definitions.

Expand Down
59 changes: 31 additions & 28 deletions selfhost/modal/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,39 +83,42 @@ def _start_vllm():
'FACTORY_SELFHOST_INPUT_MODALITIES': ','.join(S.input_modalities)})


GPU_FUNCTION = dict(image=image, gpu=S.gpu_spec, cpu=S.cpu, memory=S.memory_mib, secrets=[auth],
volumes=VOLUMES, scaledown_window=S.scaledown_minutes * MINUTES,
min_containers=0, max_containers=1, timeout=S.request_timeout_minutes * MINUTES)
# A web_server endpoint holds requests while a cold container starts. Modal's own auth stays
# off; vLLM checks the bearer key (VLLM_API_KEY).
WEB = dict(port=S.port, startup_timeout=S.startup_timeout_minutes * MINUTES)
CONCURRENT = dict(max_inputs=min(1000, S.max_num_seqs * 4)) # Modal's limit is 1000
# The short-lived helper app has no serving functions: they need the key's secret, which only
# the deployment creates, after the weights are in place.
if not HELPERS:
GPU_FUNCTION = dict(image=image, gpu=S.gpu_spec, cpu=S.cpu, memory=S.memory_mib, secrets=[auth],
volumes=VOLUMES, scaledown_window=S.scaledown_minutes * MINUTES,
min_containers=0, max_containers=1, timeout=S.request_timeout_minutes * MINUTES)
# A web_server endpoint holds requests while a cold container starts. Modal's own auth stays
# off; vLLM checks the bearer key (VLLM_API_KEY).
WEB = dict(port=S.port, startup_timeout=S.startup_timeout_minutes * MINUTES)
CONCURRENT = dict(max_inputs=min(1000, S.max_num_seqs * 4)) # Modal's limit is 1000


@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve():
_start_vllm()


if S.replicas >= 2:
@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve_b():
def serve():
_start_vllm()

if S.replicas >= 3:
@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve_c():
_start_vllm()

if S.replicas >= 4:
@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve_d():
_start_vllm()
if S.replicas >= 2:
@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve_b():
_start_vllm()

if S.replicas >= 3:
@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve_c():
_start_vllm()

if S.replicas >= 4:
@app.function(**GPU_FUNCTION)
@modal.concurrent(**CONCURRENT)
@modal.web_server(**WEB)
def serve_d():
_start_vllm()
2 changes: 1 addition & 1 deletion src/cloud-lite/board.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export class CloudLiteBoard {
threadStatus: receipt.thread_status, ...(receipt.turn_status ? { turnStatus: receipt.turn_status } : {}), eventSequence: receipt.event_seq, updated: receipt.updated } : null;
}
registerSession(agent, session) {
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(session ?? '')) throw new TypeError('Provide the real native Codex thread identity.');
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(session ?? '')) throw new TypeError('Provide the real native thread identity.');
const previous = this.db.prepare('SELECT session FROM cloud_lite_agents WHERE id=?').get(agent);
if (previous && previous.session !== session) {
this.db.prepare('DELETE FROM cloud_lite_native_runtime WHERE agent_id=?').run(agent);
Expand Down
43 changes: 31 additions & 12 deletions src/cloud-lite/bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
/** Bootstrap on the pinned upstream Node image when a custom registry is unavailable. */
export function cloudLiteBootstrap(bundleSha256) {
if (!/^[a-f0-9]{64}$/.test(bundleSha256)) throw new TypeError('Bind the exact SDK bundle SHA256.');
return `#!/bin/bash
set -euo pipefail
mkdir -p /data/factory /data/codex /opt/factory
exec > >(tee -a /data/factory/bootstrap.log) 2>&1
echo '${bundleSha256} /tmp/factory-sdk.tgz' | sha256sum -c -
apt-get update
apt-get install -y --no-install-recommends git gh openssh-client ca-certificates curl procps python3 make g++ xz-utils bubblewrap
curl -fsSL https://github.com/openai/codex/releases/download/rust-v0.162.1/codex-x86_64-unknown-linux-musl.tar.gz -o /tmp/codex.tar.gz
const CLAUDE_CODE_VERSION = '2.1.311';
const CLAUDE_CODE_LINUX_X64_SHA256 = '86dc1fcc69498159eaf15f446613e847010f9036bfbbdffd4c49350be8489ada';
const programInstall = {
codex: {
folder: '/data/codex', packages: '', home: 'CODEX_HOME=/data/codex',
install: `curl -fsSL https://github.com/openai/codex/releases/download/rust-v0.162.1/codex-x86_64-unknown-linux-musl.tar.gz -o /tmp/codex.tar.gz
echo '86f268d81b898f3e144c5ecff2ad2fda2c5802e042d6195b72538fe0fa125057 /tmp/codex.tar.gz' | sha256sum -c -
tar -xzf /tmp/codex.tar.gz -C /usr/local/bin
mv /usr/local/bin/codex-x86_64-unknown-linux-musl /usr/local/bin/codex
curl -fsSL https://github.com/openai/codex/releases/download/rust-v0.162.1/codex-code-mode-host-x86_64-unknown-linux-musl.tar.gz -o /tmp/code-mode.tar.gz
echo '458239ea45c1fc508d89695f180ab352f2834709a8a46929a4466f4f04399ada /tmp/code-mode.tar.gz' | sha256sum -c -
tar -xzf /tmp/code-mode.tar.gz -C /usr/local/bin
mv /usr/local/bin/codex-code-mode-host-x86_64-unknown-linux-musl /usr/local/bin/codex-code-mode-host
mv /usr/local/bin/codex-code-mode-host-x86_64-unknown-linux-musl /usr/local/bin/codex-code-mode-host`,
},
// socat carries Claude Code's sandbox network proxy.
claude: {
folder: '/data/claude', packages: ' socat', home: 'CLAUDE_CONFIG_DIR=/data/claude',
install: `curl -fsSL https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION}/linux-x64/claude -o /tmp/claude
echo '${CLAUDE_CODE_LINUX_X64_SHA256} /tmp/claude' | sha256sum -c -
install -m 755 /tmp/claude /usr/local/bin/claude
rm -f /tmp/claude`,
},
};

export function cloudLiteBootstrap(bundleSha256, { workerProgram = 'codex' } = {}) {
if (!/^[a-f0-9]{64}$/.test(bundleSha256)) throw new TypeError('Bind the exact SDK bundle SHA256.');
const program = programInstall[workerProgram];
if (!program) throw new TypeError('Unknown worker program.');
return `#!/bin/bash
set -euo pipefail
mkdir -p /data/factory ${program.folder} /opt/factory
exec > >(tee -a /data/factory/bootstrap.log) 2>&1
echo '${bundleSha256} /tmp/factory-sdk.tgz' | sha256sum -c -
apt-get update
apt-get install -y --no-install-recommends git gh openssh-client ca-certificates curl procps python3 make g++ xz-utils bubblewrap${program.packages}
${program.install}
tar -xzf /tmp/factory-sdk.tgz --strip-components=1 -C /opt/factory
cd /opt/factory
npm install --omit=dev --ignore-scripts --no-audit --no-fund
npm install --prefix /opt/flujo-bash --no-audit --no-fund @mario.andreschak/mcp-bash@3.46.2
export HOME=/data CODEX_HOME=/data/codex
export HOME=/data ${program.home}
node --input-type=module - <<'FACTORY_PIN_SOURCE'
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
Expand Down
Loading
Loading