Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@

# 🏢 Agent Office

Named teams can be loaded per project from `.agent-office/worker-profiles.json`.
The **Team** button shows the full roster count. Available and held members appear
in the 3D office before they have a task; only assigning a task starts a CLI worker.
An optional finite [autopilot supervisor](docs/agents.md#optional-finite-autopilot)
can ask an idle coordinator to select the next eligible issue after the team finishes.
**Menu → Named workers** preserves each person's name, role, exact model and
effort, and keeps held profiles visible without starting them. See [Choosing an agent](docs/agents.md).

**A 3D office your team shares with its coding agents.**

Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it,
Expand Down
42 changes: 42 additions & 0 deletions bin/autopilot/policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createHash } from 'node:crypto';

export const BUSY = new Set(['starting', 'working', 'needs_input']);
const HELD = new Set(['execution:held', 'no-agent', 'status:held', 'status:paused', 'status:blocked', 'status:done', 'status:archived', 'status:review', 'status:in-progress']);

export function fingerprint(issue) {
return createHash('sha256').update(JSON.stringify([issue.number, issue.updatedAt, issue.body, issue.labels.map(label => label.name).sort()])).digest('hex');
}

/** Only a ready task can execute. Other unheld issues may be assessed for a task contract. */
export function candidates(issues, { owner, reserved = [], completed = [], inspected = {} }) {
const reservedIds = new Set(reserved);
const completedRuns = new Set(completed);
return issues.filter(issue => {
const labels = new Set(issue.labels.map(label => label.name.toLowerCase()));
if (issue.author?.login !== owner || reservedIds.has(issue.number) || issue.assignees?.length) return false;
if ([...HELD].some(label => labels.has(label))) return false;
const runs = [...(issue.body || '').matchAll(/<!-- fox-runtime-run:([A-Za-z0-9-]{8,80}) -->/g)].map(match => match[1]);
if (runs.some(run => completedRuns.has(run))) return false;
return inspected[issue.number] !== fingerprint(issue);
}).map(issue => ({ ...issue, mode: issue.labels.some(label => label.name === 'execution:ready') ? 'validate-ready-contract' : 'prepare-contract-only' }));
}

export function gate({ now, config, state, workers, externalPending = [] }) {
if (!config.enabled) return 'disabled';
if (now >= config.expiresAt || state.rounds.length >= config.maxRounds) return 'budget-finished';
if (externalPending.length) return 'external-reconciliation';
if (state.rounds.some(round => round.state === 'uncertain')) return 'needs-reconciliation';
if (workers.some(worker => BUSY.has(worker.status))) return 'waiting-for-team';
return 'ready';
}

export function decisionsOf(value, round) {
if (value?.cycle_id !== round.id || !Array.isArray(value.decisions)) throw new Error('A matching cycle manifest is required');
const allowed = new Set(round.issues.map(issue => issue.number));
const seen = new Set();
return value.decisions.map(decision => {
if (!allowed.has(decision.issue) || seen.has(decision.issue) || !['assigned', 'blocked', 'already-claimed', 'needs-contract', 'completed'].includes(decision.action) || typeof decision.reason !== 'string' || !decision.reason.trim()) throw new Error('Invalid decision');
seen.add(decision.issue);
return { issue: decision.issue, action: decision.action, reason: decision.reason.slice(0, 2000) };
});
}
128 changes: 128 additions & 0 deletions bin/office-autopilot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/** An opt-in, finite supervisor. It wakes an idle coordinator; it never edits GitHub issues. */
import { readFileSync, writeFileSync, renameSync, mkdirSync, lstatSync, existsSync, realpathSync } from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { randomUUID } from 'node:crypto';
import { WebSocket } from 'ws';
import { candidates, decisionsOf, fingerprint, gate } from './autopilot/policy.mjs';

const exec = promisify(execFile);
const configFile = process.argv[2];
if (!configFile) throw new Error('Usage: office-autopilot.js /absolute/path/to/local-config.json');
const config = JSON.parse(readFileSync(configFile, 'utf8'));
if (!Number.isSafeInteger(config.maxRounds) || config.maxRounds < 1 || config.maxRounds > 20 || !Number.isFinite(config.expiresAt) || !Number.isSafeInteger(config.pollSeconds) || config.pollSeconds < 15 || config.pollSeconds > 600 || !Number.isSafeInteger(config.maxNewWorkers) || config.maxNewWorkers < 1 || config.maxNewWorkers > 4) throw new Error('Finite rounds, expiry, polling and worker limits are required');
const project = realpathSync(config.projectDir);
mkdirSync(path.dirname(config.stateFile), { recursive: true });
const read = file => JSON.parse(readFileSync(file, 'utf8'));
function write(file, value) {
const tmp = file + '.' + randomUUID() + '.tmp';
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, file);
}
const state = existsSync(config.stateFile) ? read(config.stateFile) : { rounds: [], inspected: {} };
// A crash after preparation may have delivered the request. Never blindly replay it.
for (const round of state.rounds) if (round.state === 'prepared') round.state = 'uncertain';
write(config.stateFile, state);
let cookie = '';
const base = config.baseUrl;
// HTTP cookies are scoped by the native host/port. Use that same origin for WS
// rather than mixing a public proxy Host with a loopback login cookie.
const origin = new URL(base).origin;
async function request(route, body) {
const response = await fetch(base + route, { method: body === undefined ? 'GET' : 'POST', headers: { Host: new URL(origin).host, Origin: origin, Cookie: cookie, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15_000) });
if (!response.ok) { const error = new Error(`Office request refused (${response.status})`); error.status = response.status; throw error; }
return response;
}
async function login() {
const response = await request('/api/login', { password: readFileSync(config.passwordFile, 'utf8').trim() });
cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ');
if (!cookie) throw new Error('Office session unavailable');
}
async function snapshot() {
return new Promise((resolve, reject) => {
const url = new URL('/ws', base); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.search = new URLSearchParams({ floor: config.floor, name: 'Office autopilot' }).toString();
const ws = new WebSocket(url, { headers: { Host: new URL(origin).host, Origin: origin, Cookie: cookie } });
const timer = setTimeout(() => { ws.terminate(); reject(new Error('Office snapshot timeout')); }, 10_000);
ws.on('error', () => { clearTimeout(timer); reject(new Error('Office snapshot unavailable')); });
ws.on('message', raw => {
const message = JSON.parse(String(raw));
if (message.t === 'welcome') { clearTimeout(timer); ws.close(); resolve(message); }
});
});
}
function status(kind, summary, extra = {}) {
write(path.join(project, '.agent-office/autopilot-status.json'), { state: kind, summary, at: Date.now(), rounds: state.rounds.length, maxRounds: config.maxRounds, expiresAt: config.expiresAt, ...extra });
}
function manifestFile(worker, round) {
const root = realpathSync(path.resolve(project, worker.worktree?.path || '.'));
if (root !== project && !root.startsWith(project + path.sep)) throw new Error('Coordinator outside project');
const output = path.join(root, 'outputs/office-autopilot', round.id + '.json');
if (!existsSync(output)) return;
if (!lstatSync(output).isFile() || realpathSync(output) !== output || lstatSync(output).size > 100_000) throw new Error('Invalid cycle manifest file');
return output;
}
async function tick() {
const current = JSON.parse(readFileSync(configFile, 'utf8'));
// The running process cannot extend its own campaign after a config edit.
const effective = { ...config, enabled: current.enabled === true, expiresAt: Math.min(config.expiresAt, current.expiresAt) };
const budget = gate({ now: Date.now(), config: effective, state, workers: [] });
if (budget !== 'ready' && !state.rounds.some(round => round.state === 'sent')) return status(budget, 'Automatic selection paused or its finite campaign has ended.');
await login();
const office = await snapshot();
const workers = office.workers || [];
const coordinator = workers.find(worker => worker.name === config.coordinatorName);
const outstanding = state.rounds.find(round => round.state === 'sent');
if (outstanding) {
if (!coordinator) return status('needs-reconciliation', 'The coordinator of a sent cycle is missing.');
const file = manifestFile(coordinator, outstanding);
if (file && !['starting', 'working', 'needs_input'].includes(coordinator.status)) {
const decisions = decisionsOf(read(file), outstanding);
for (const decision of decisions) state.inspected[decision.issue] = outstanding.issues.find(issue => issue.number === decision.issue).fingerprint;
Object.assign(outstanding, { state: 'recorded', decisions, manifest: file, finishedAt: Date.now() });
write(config.stateFile, state);
} else return status('waiting-for-coordinator', 'The current selection cycle is still running; it will not be replayed.');
}
const sourceStatus = config.externalStatusFile ? read(config.externalStatusFile) : {};
const sourceQueue = config.externalQueueFile ? read(config.externalQueueFile) : { tasks: [] };
const waiting = gate({ now: Date.now(), config: effective, state, workers, externalPending: sourceStatus.pending_reconciliation || [] });
if (waiting !== 'ready') return status(waiting, 'Waiting for existing work, QA or reconciliation before selecting another issue.', { activeWorkers: workers.filter(worker => ['starting', 'working', 'needs_input'].includes(worker.status)).map(worker => worker.name) });
const { stdout } = await exec(config.githubCli || 'gh', ['issue', 'list', '--repo', config.repository, '--state', 'open', '--limit', '500', '--json', 'number,title,body,labels,assignees,updatedAt,author,url'], { cwd: project, timeout: 30_000, maxBuffer: 10_000_000 });
const issues = JSON.parse(stdout);
// A live native ready-contract dispatcher owns all ready execution, including
// a new stage it has not claimed yet. This supervisor must not race it.
const nativeReady = sourceStatus.discover_ready ? issues.filter(issue => issue.labels.some(label => label.name === 'execution:ready')).map(issue => issue.number) : [];
const available = candidates(issues, { owner: config.repository.split('/')[0], reserved: [...(config.reservedIssues || []), ...nativeReady, ...(sourceQueue.tasks || []).map(task => task.issue)], completed: sourceStatus.completed_jobs || [], inspected: state.inspected });
if (!available.length) return status('waiting-for-eligible-issue', 'No new unheld, unclaimed issue is eligible; completed stages will not be replayed.', { openIssues: issues.length, heldIssues: issues.filter(issue => issue.labels.some(label => label.name === 'execution:held')).length });
const profiles = (await (await request('/api/worker-profiles?floor=' + encodeURIComponent(config.floor))).json()).profiles;
const profile = profiles.find(profile => profile.name === config.coordinatorName && profile.enabled);
if (!profile) return status('coordinator-held', 'The configured coordinator is unavailable; no substitute was selected.');
const round = { id: 'cycle-' + randomUUID(), state: 'prepared', at: Date.now(), issues: available.slice(0, 12).map(issue => ({ number: issue.number, fingerprint: fingerprint(issue) })) };
const output = 'outputs/office-autopilot/' + round.id + '.json';
const prompt = `AUTOPILOT ${round.id}\nUser authorized autonomous selection of board issues in this experimental copy. Continue as the named coordinator, preserving exact team routes and source holds. First collect completed workers' outputs and independent QA. Select from the issue candidates below after reading each current Issue and all comments; board titles are not task contracts. Check current writers and dependencies.\n\nHard limits: at most ${config.maxNewWorkers} new worker launches this cycle, at most 15 minutes per worker, no extra agents beyond that, no CLI/model/effort fallback, no unbounded polling. Reuse an idle existing worker by office-workers tell rather than duplicate its persona. The current available profiles are ${JSON.stringify(profiles.filter(profile => profile.enabled).map(profile => ({ name: profile.name, provider: profile.provider, model: profile.model, effort: profile.effort })))}.\n\nIssues marked prepare-contract-only are for read-only assessment and a local proposed task contract; do not execute their underlying task. Only validate-ready-contract items may execute, after checking a complete first-party typed contract, fresh unique stage, correct writer/reviewer, dependencies and scoped write permission. Never remove holds, replay completed stages, claim another writer's task, or count a draft as acceptance. No original Orch mutation, GitHub issue/PR/label/state writes, production changes, publishing, credential operations, purchases, external messages or new recurring jobs. Local worktrees and outputs only. Independent QA stays separate. Preserve all more specific source STOPs.\n\n${config.instructions || ''}\n\nCandidates: ${JSON.stringify(available.slice(0, 12).map(issue => ({ number: issue.number, title: issue.title, mode: issue.mode, url: issue.url })))}\n\nBefore ending, write ONLY this selection manifest in your worktree at ${output}: {"cycle_id":"${round.id}","decisions":[{"issue":NUMBER,"action":"assigned|blocked|already-claimed|needs-contract|completed","reason":"concrete evidence and artifact/worker id"}]}. Use needs-contract for preparation that has no execution grant. Use completed only for actual accepted scope; selection is not completion. Cover each candidate you assessed, report artifact paths, then stop. This finite supervisor will select the next round later.`;
state.rounds.push(round); write(config.stateFile, state);
try {
const response = await request('/api/worker-profiles?floor=' + encodeURIComponent(config.floor), { id: profile.id, prompt, reuseIdle: true });
const result = await response.json();
Object.assign(round, { state: 'sent', workerId: result.workerId, sentAt: Date.now() });
write(config.stateFile, state);
status('selecting', 'The coordinator is selecting the next suitable issue and checking its task contract.', { cycle: round.id });
} catch (error) {
round.state = error.status === 409 ? 'refused' : 'uncertain'; write(config.stateFile, state);
status(round.state === 'refused' ? 'waiting-for-team' : 'needs-reconciliation', round.state === 'refused' ? 'The coordinator became busy or its profile was held; no duplicate task was sent.' : 'A dispatch response was uncertain; automatic replay is blocked.');
}
}
let stopped = false;
process.on('SIGTERM', () => { stopped = true; });
process.on('SIGINT', () => { stopped = true; });
while (!stopped) {
try { await tick(); } catch { cookie = ''; status('unavailable', 'A required source or service could not be verified; no new work was selected.'); }
if (process.argv.includes('--once')) break;
await new Promise(resolve => {
const finish = () => { clearTimeout(timer); process.off('SIGTERM', finish); process.off('SIGINT', finish); resolve(); };
const timer = setTimeout(finish, config.pollSeconds * 1000);
process.once('SIGTERM', finish); process.once('SIGINT', finish);
});
}
50 changes: 50 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
# Choosing an agent

Named teams can be configured per floor in `.agent-office/worker-profiles.json`.
The **Team** button is always visible on a floor with profiles and counts the entire
roster, including held members. In the office map, members without a running worker
appear at free desks or in the team area, labelled **Available** or **Held by source**.
Press **E** at a member to view their profile. These avatars do not start terminals
or count as running workers; source holds stay in effect when assigning a task.
Open **Menu → Named workers** to see each name, role, provider, model, effort and
source hold. **Give a task** starts that exact profile in its own worktree and
uses its name at the desk. It never substitutes a missing model or effort.
Profiles with `enabled: false`, nonempty `holds`, or an invalid route cannot start.
The file contains an array of `{id,name,description,provider,model,effort,enabled,holds}`.
Keep credentials out of it. This configuration does not change GitHub issues or
release source execution holds.

## Optional finite autopilot

`node bin/office-autopilot.js /absolute/path/to/config.json` runs an opt-in supervisor.
It waits for current workers and QA, then asks a configured named coordinator to
select suitable issues. It never changes GitHub tasks itself. Source holds,
assignees, reserved issues, completed native stage markers and prior inspections
exclude work. An unheld issue without `execution:ready` is **contract preparation
only**; ready work still requires validating its current contract and dependencies.
The named-team window shows the supervisor's actual status.

The local JSON configuration requires `baseUrl`, `origin`, `floor`, `repository`,
`projectDir`, `passwordFile` (the office password file), `stateFile`,
`coordinatorName`, `enabled`, `expiresAt` (Unix milliseconds), `maxRounds` (1–20),
`maxNewWorkers` (1–4 per round), and `pollSeconds` (15–600). Optional
`externalStatusFile`/`externalQueueFile` read a native coordinator's
`completed_jobs`, `pending_reconciliation`, and `tasks[].issue`; `reservedIssues`
protects other known writers. `instructions` adds local project restrictions.
Keep this configuration and its credentials outside Git. Run only one instance,
using a supervisor and an OS lock such as `flock`; do not extend a running
campaign's expiry or reuse its state for another campaign.

Each cycle is written to the state journal before sending. A lost response or
restart during dispatch blocks replay for reconciliation. The coordinator writes
a cycle-specific decision manifest before another selection. Rounds, wall-clock
expiry and per-worker 15-minute instructions bound the campaign; use the launcher’s
own resource/lease guards as well. This supervisor does not install or bypass those
guards. `--once` runs one observation for diagnostics. The authenticated profile
POST accepts `reuseIdle: true` to reuse a matching idle/offline coordinator, refusing
busy workers, held profiles and changed model routes.

Set `AGENT_OFFICE_RESTORE_WORKERS=interrupted` to leave completed or failed
terminals asleep after an office restart or a page/floor visit, while retaining live terminals and
resuming genuinely interrupted turns. The default still wakes every saved worker.
This prevents a server update from opening an idle CLI for every old desk and
reserving capacity that the next queued task needs.

Back to the [README](../README.md).

Every new worker starts on the office's **Default worker**: a provider, model and effort an admin picks in ⚙️ Settings → **🤖 Workers** (the `--agent` with its own default model until someone does). The hire, ask, queue, issue and meeting windows show it on one line; click **✏️ Edit** there to pick **Claude Code**, **OpenCode**, **Codex**, **Grok**, **Muse**, **DeepSeek Harness**, **Pi** or **Cursor**, a model and an effort for just that worker or task, and **↺ Use the default** to go back. Board agents and tasks the Queue agent adds, which nobody picks for, start on the default too. Existing workers keep their provider when prompted or resumed, and queued tasks keep their choice when retried or restored after a restart.
Expand Down
Loading