Skip to content
Closed
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i

## What it is

- **AI Office zones.** Open **Training Campus & Main Office** from the ☰ menu (or **Zones** in `/lite`) to browse departments and their project floors. Existing projects start in Main Office / Software Engineering. Admins can add or edit departments and assign projects. Training Campus is currently an organizational view, not an execution sandbox; see [AI Office architecture](docs/ai-office-architecture.md).

- **A floor per project.** Ride the elevator, pick one of your GitHub repos, and the office clones it (showing how far along it is) and opens a floor for it. Every worker, board and queue on that floor works in that checkout.
- **Workers at desks.** Walk up to an empty desk, press **E**, and pick Claude Code, Codex, OpenCode, Grok, Muse, DeepSeek Harness, Pi or Cursor, each with its model and reasoning effort. The agent's live terminal shows on its laptop, and anyone can open it and type.
- **Talk instead of typing.** Hold **Ctrl+Space** (or the **🎤**) in a worker's terminal or a prompt box and say what you want: it's typed in for you to send. Your browser does the listening, so there's nothing to install.
Expand Down
Binary file added docs/ai-office-3d-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions docs/ai-office-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# AI Office architecture

The first slice adds an organizational layer around the existing building. A floor still owns one project checkout and its workers, queue and boards. No worker or floor record is rewritten.

```
OfficeZone (training | main)
└── Department
└── Floor (project checkout)
└── Worker (running session)
```

## Boundaries

- Zones and departments are persistent, versioned metadata owned by the office, not by a floor. Their defaults are installed once: Training Campus with its seven logical labs, and Main Office with its eleven departments.
- A floor has one department assignment. Existing floors without an assignment resolve to Main Office / Software Engineering. Assignment does not move a checkout or change its workers.
- Client navigation selects a zone and department and uses the existing `floor.go` path for travel. The selected zone is a browser view preference, not an execution boundary.
- The new HTTP API uses the existing signed session. Reads are available to signed-in users; only office admins can change department or floor assignments. The server derives the actor from the session.
- Provider, model, role, AgentProfile, worker and task remain distinct concepts. AgentProfiles, evaluation, promotion and routing belong to later slices.

## Persistence and migration

`zones.json` stores a schema version, zone/department definitions and floor assignments in the office data directory. The server validates it on load and writes a replacement file before renaming it into place. An absent file is initialized with defaults. Existing floors are resolved to Main Office / Software Engineering without editing `floors.json`; new and removed floors are reconciled by ID. Reloading does not duplicate defaults or assignments.

Department and floor-assignment edits append an actor-derived audit record in the same atomic metadata write. The audit records are not exposed in the navigation response.

Malformed or unsupported metadata must fail closed with a logged error. It must not overwrite the saved file or silently move a floor. Keep all original floor, worker, queue, account and worktree files untouched.

## Security boundary

Training Campus is an organizational view in this slice. Existing git worktrees isolate checkouts and branches, but all worker processes still run as the same OS user. Training agents must not be treated as sandboxed. The Sandbox Lab needs an execution boundary, credential separation, resource limits and enforced tool policy before untrusted agents run there. Promotion remains unavailable until evaluation, security review and human approval are implemented.

## Delivery sequence

1. Zones, departments, metadata store and legacy floor resolution.
2. Authenticated API and 3D/`/lite` navigation over the current floor travel path.
3. Persistent AgentProfiles and project stages.
4. Evaluation, promotion, approval and audit records.
5. Department orchestration and assisted routing.

Each slice keeps the existing protocol and floor behavior valid and is verified with typecheck, tests, build and a browser screenshot for UI changes.
Binary file added docs/ai-office-lite.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions docs/code-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ They're in `core/registry.ts`, and each is a field of `ctx`. Every registration

## Server

### AI Office structure

`src/server/office-structure.ts` owns the versioned Zone/Department metadata and resolves old floors to Main Office / Software Engineering without changing `floors.json`. `src/server/http/routes/structure.ts` exposes signed-in reads and admin-only edits. Types live in `src/shared/protocol/zones.ts` and `departments.ts`; the 3D and `/lite` views share `src/client/ui/structure.ts`. The data and later delivery phases are described in [AI Office architecture](ai-office-architecture.md).

- **`server.ts`** is the composition root: `startServer` builds the office's context a stage at a time, starts the hook server, opens the floors, and hands the context to the HTTP handler and the WebSocket.
- **`office/`** is that context (`Ctx` in `office/context.ts`: the building-wide services, sending to browsers, the floors, the people in the office, going between floors, and the checks before something happens) and the pieces that make it.
- **`ws/`** takes the sockets. Each domain's messages are handled in `ws/handlers/<domain>.ts`, a map from message type to handler that `satisfies HandlerMap<ItsClientMsg>`, and `ws/handlers/index.ts` puts them all in one `HandlerMap<ClientMsg>`: a message type with no handler, or a handler for a type that doesn't exist, fails the typecheck. The same file lists the features that keep something per person on a floor (`features`: what each lets go of when someone is `leaving` a floor, and once their socket has `closed`) and the pieces of what someone arriving on a floor is sent (`views`, one for each field of `FloorView`, put together by `office/views.ts`).
Expand Down
2 changes: 2 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Features

- **AI Office zones.** Training Campus and Main Office organize project floors by department. The zone browser works in 3D and `/lite`; admins configure departments and project assignments. Existing floors resolve to Main Office / Software Engineering. Training Campus does not yet isolate running workers; see [AI Office architecture](ai-office-architecture.md).

Everything in the office, room by room. Back to the [README](../README.md).

- **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick one of the repositories your `gh` login can see (or type `owner/name`) and the office clones it into the workspace folder, `~/agent-office/<owner>/<repo>` unless you pick another (**📁 Change folder** right there, for admins). While it clones, its floor on the panel says how far along it is (*Downloading 64% · 231 MiB · 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **🗑** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures, and adding the same repository again moves back into it (as long as the workspace folder hasn't moved since). That goes for the project the office was started in (`agent-office <dir>`) too: it stays off after a restart, and the office keeps its own data in that folder as before.
Expand Down
2 changes: 2 additions & 0 deletions src/client/features/hud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { toggleFloorMenu } from '../../ui/floormenu';
import { openHelp } from '../../ui/hud';
import { mountHud } from '../../ui/menu';
import { openServices } from '../../ui/services';
import { openStructure } from '../../ui/structure';
import { openSettings, type SettingsPane } from '../../ui/settings';
import { needsSigningIn, openSignIns } from '../../ui/signins';
import { openTeam } from '../../ui/team';
Expand Down Expand Up @@ -69,6 +70,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) {
// The office has its bookshelf for them; a map of its own may not.
{ id: 'docs', icon: '📚', label: 'Docs', section: 'Open', shown: () => !inOffice(), title: () => 'Read the project’s docs', run: parts.bookshelf.showBookshelf },
{ id: 'elevator', icon: '🛗', label: () => (inOffice() ? 'Elevator' : 'Floors'), section: 'Open', count: () => store.floors.reduce((n, f) => n + (f.id === store.floor ? 0 : f.waiting), 0), title: () => (inOffice() ? 'Ride to another project' : 'Go to another project, or add one'), run: travel.showElevator },
{ id: 'zones', icon: '🏢', label: 'Training Campus & Main Office', section: 'Open', run: () => openStructure(travel.switchFloor) },
{ id: 'roof', icon: '🍸', label: 'Rooftop bar', section: 'Open', shown: () => !core.upTop && inOffice() && builtFloors().length > 0, title: () => 'Ride the elevator up to the roof: a DJ, drinks and the city', run: () => travel.ride(ROOF) },
// In voice, V is push to talk, so leaving is only from here.
{ id: 'voice', icon: '🎙️', label: () => (voice.inVoice ? 'Leave voice' : 'Join voice'), section: 'Together', key: () => (voice.inVoice ? undefined : 'V'), on: () => voice.inVoice, blocked: noMedia, run: () => void talk.toggleVoice() },
Expand Down
1 change: 1 addition & 0 deletions src/client/lite.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<span class="lite-floor-label">Floor</span>
<select id="floor" aria-label="Floor"></select>
</label>
<button id="btn-zones" class="btn" type="button" title="Training Campus and Main Office">🏢 Zones</button>
<a id="to-3d" class="btn" href="/?3d=1" title="The 3D office, where you walk around">🏢 3D</a>
</header>
<main class="lite-main">
Expand Down
2 changes: 2 additions & 0 deletions src/client/lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { openQueue } from './ui/queue';
import { openAsk } from './ui/ask';
import { openMeeting, type MeetingPreset } from './ui/meeting';
import { openSignIns } from './ui/signins';
import { openStructure } from './ui/structure';
import { modelBadge, providerLabel } from './ui/provider';
import { byUrgency, waitingInOrder, waitingLabel } from './nextup';
import { askNotifyPermission, DesktopNotifier, notifyPermission, waitingOnSomeone } from './notify';
Expand Down Expand Up @@ -95,6 +96,7 @@ function offTheRoof() {

// ---- The floor you're on ------------------------------------------------------------------------
const floorSelect = $('floor') as HTMLSelectElement;
$('btn-zones').addEventListener('click', () => openStructure((floor) => net.send({ t: 'floor.go', floor })));
const floorLabel = (f: FloorInfo) => `${f.name}${f.cloning ? ` (${cloneLabel(f.clone)})` : f.waiting ? ` · 🙋 ${f.waiting}` : ''}`;

function renderFloors() {
Expand Down
14 changes: 14 additions & 0 deletions src/client/ui/structure.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.structure-window { width: min(680px, 96vw); max-height: 88dvh; display: flex; flex-direction: column; }
.structure-window > header { display: flex; align-items: center; justify-content: space-between; }
.structure-body { overflow: auto; padding: 12px 18px 20px; display: grid; gap: 10px; }
.structure-tabs { display: flex; gap: 8px; flex-wrap: wrap; }
.structure-note { margin: 0; color: var(--muted); font-size: 13px; }
.structure-department { border: 2px solid var(--ink); border-left: 8px solid; border-radius: var(--radius); padding: 9px; display: grid; gap: 7px; background: #fff; }
.structure-department-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.structure-floor { width: 100%; justify-content: flex-start; }
.structure-empty { color: var(--muted); font-size: 13px; }
.structure-assign, .structure-add, .structure-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding-top: 10px; border-top: 1px solid var(--ink); }
.structure-assign h3, .structure-add h3 { width: 100%; margin: 0; font-size: 15px; }
.structure-assign select, .structure-add input, .structure-edit input { min-height: 38px; }
.structure-editor { width: min(420px, 94vw); padding: 16px; }
.structure-edit label { display: flex; align-items: center; gap: 8px; }
98 changes: 98 additions & 0 deletions src/client/ui/structure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import './structure.css';
import type { Department, OfficeStructure, OfficeZone } from '../../shared/protocol';
import { store } from '../state';
import { h, openModal, toast } from './dom';

const CHOICE = 'agent-office-zone';

async function structure(): Promise<OfficeStructure> {
const response = await fetch('/api/zones', { cache: 'no-store' });
if (!response.ok) throw new Error(response.status === 401 ? 'Sign in again' : 'Could not load office zones');
return response.json() as Promise<OfficeStructure>;
}

async function mutate(url: string, method: 'POST' | 'PATCH', body: unknown): Promise<OfficeStructure> {
const response = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const data = await response.json() as OfficeStructure & { error?: string };
if (!response.ok) throw new Error(data.error ?? 'Could not save office structure');
return data;
}

export function selectedZone(zones: readonly OfficeZone[]): string {
const saved = localStorage.getItem(CHOICE);
return zones.some((z) => z.id === saved) ? saved! : 'main';
}

/** Zone and department navigation shared by 3D and /lite. Floor travel remains the existing path. */
export function openStructure(go: (floorId: string) => void) {
const body = h('div.structure-body', {}, 'Loading office structure…');
const panel = h('section.modal.structure-window', { 'aria-label': 'Office zones' }, h('header', {}, h('h2', {}, '🏢 AI Office')), body);
const modal = openModal(panel);
let data: OfficeStructure | undefined;
let zoneId = 'main';

const fail = (err: unknown) => toast((err as Error).message, 'error');
const save = async (url: string, method: 'POST' | 'PATCH', value: unknown) => {
try { data = await mutate(url, method, value); render(); return true; } catch (err) { fail(err); return false; }
};

function editor(d: Department) {
const name = h('input', { type: 'text', value: d.name, maxlength: 100, 'aria-label': 'Department name' }) as HTMLInputElement;
const color = h('input', { type: 'color', value: d.color, 'aria-label': 'Department color' }) as HTMLInputElement;
const enabled = h('input', { type: 'checkbox', 'aria-label': 'Enabled' }) as HTMLInputElement;
enabled.checked = d.enabled;
enabled.disabled = d.id === 'software-engineering';
const form = h('form.structure-edit', {}, h('label', {}, 'Name', name), h('label', {}, 'Color', color), h('label', {}, 'Enabled', enabled), h('button.btn.primary', { type: 'submit' }, 'Save'));
const editModal = openModal(h('section.modal.structure-editor', { 'aria-label': `Edit ${d.name}` }, h('header', {}, h('h2', {}, `Edit ${d.name}`)), form));
form.addEventListener('submit', (event) => {
event.preventDefault();
void save(`/api/departments/${encodeURIComponent(d.id)}`, 'PATCH', { name: name.value, color: color.value, enabled: enabled.checked }).then((ok) => { if (ok) editModal.close(); });
});
}

function render() {
if (!data) return;
if (!data.zones.some((z) => z.id === zoneId)) zoneId = 'main';
const zone = data.zones.find((z) => z.id === zoneId)!;
const tabs = h('nav.structure-tabs', { 'aria-label': 'Office zones' }, ...data.zones.map((z) => h('button.btn', {
type: 'button', 'aria-pressed': String(z.id === zoneId), class: z.id === zoneId ? 'primary' : '',
onclick: () => { zoneId = z.id; localStorage.setItem(CHOICE, zoneId); render(); },
}, z.name)));
const departments = data.departments.filter((d) => d.zoneId === zoneId);
const sections = departments.map((d) => {
const floors = store.floors.filter((f) => data!.floorDepartments[f.id] === d.id);
const card = h('section.structure-department', { style: `border-left-color:${d.color}` },
h('div.structure-department-head', {}, h('strong', {}, `${d.icon ?? '🏢'} ${d.name}`),
store.me.admin ? h('button.btn', { type: 'button', onclick: () => editor(d) }, 'Edit') : null),
...floors.map((f) => h('button.btn.structure-floor', { type: 'button', disabled: !!f.cloning, onclick: () => { modal.close(); go(f.id); } }, f.name)),
floors.length ? null : h('span.structure-empty', {}, 'No projects yet'),
);
return card;
});
const assign = store.me.admin && store.floors.length ? assignment(data) : null;
const add = store.me.admin ? addDepartment() : null;
body.replaceChildren(tabs, h('p.structure-note', {}, zone.type === 'training' ? 'Training Campus is organizational only. Workers are not sandboxed yet.' : 'Main Office projects and departments.'), ...sections, ...(assign ? [assign] : []), ...(add ? [add] : []));
}

function assignment(view: OfficeStructure) {
const floors = h('select', { 'aria-label': 'Project' }) as HTMLSelectElement;
floors.replaceChildren(...store.floors.filter((f) => !f.cloning).map((f) => h('option', { value: f.id }, f.name)));
const depts = h('select', { 'aria-label': 'Department' }) as HTMLSelectElement;
depts.replaceChildren(...view.departments.filter((d) => d.enabled).map((d) => h('option', { value: d.id }, `${d.zoneId}: ${d.name}`)));
floors.addEventListener('change', () => { depts.value = view.floorDepartments[floors.value] ?? 'software-engineering'; });
depts.value = view.floorDepartments[floors.value] ?? 'software-engineering';
const form = h('form.structure-assign', {}, h('h3', {}, 'Assign project'), floors, depts, h('button.btn.primary', { type: 'submit' }, 'Assign'));
form.addEventListener('submit', (event) => { event.preventDefault(); void save('/api/floor-department', 'POST', { floorId: floors.value, departmentId: depts.value }); });
return form;
}

function addDepartment() {
const slug = h('input', { type: 'text', placeholder: 'slug', required: true, maxlength: 40, pattern: '[a-z0-9][a-z0-9-]*', 'aria-label': 'Department slug' }) as HTMLInputElement;
const name = h('input', { type: 'text', placeholder: 'Name', required: true, maxlength: 100, 'aria-label': 'Department name' }) as HTMLInputElement;
const form = h('form.structure-add', {}, h('h3', {}, 'Add department'), slug, name, h('button.btn', { type: 'submit' }, 'Add'));
form.addEventListener('submit', (event) => { event.preventDefault(); void save('/api/departments', 'POST', { zoneId, slug: slug.value, name: name.value, color: zoneId === 'training' ? '#76a88a' : '#6175b5' }); });
return form;
}

void structure().then((view) => { if (!modal.backdrop.isConnected) return; data = view; zoneId = selectedZone(view.zones); render(); }).catch((err) => { body.textContent = (err as Error).message; });
}
Loading