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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i
- **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer.
- **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others.
- **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`).
- **Workers with lives of their own.** Each hire is dealt a persona for its floor, and when it has nothing to do it wanders the office — sits on the couch, watches the TV, plays the arcade, chats with coworkers in speech bubbles — each with its own temperament, voice and favourites, and a local Ollama model can even write their conversations and remember them. The moment work arrives, it hurries back to its desk.
- **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard.

- **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)).
Expand Down
4 changes: 4 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Everything in the office, room by room. Back to the [README](../README.md).
- **Pictures on the walls.** Press **F** (or **🖼️ Hang a picture** in the **☰** menu) and paste a link to any image online: a team photo, a diagram, a meme. Pick one of six frames, then aim at a wall and click to hang it. Scroll to size it first. Where it can't go, over a window or a board, the preview turns red. Everyone sees it right away, and it stays up across restarts. Look at a picture and press **E** for a closer look, or to move, edit or take it down.
- **A whiteboard to draw on together.** A whiteboard on wheels stands out on the floor between the desks and the lounge. Press **E** at it (or **📝 Whiteboard** in the **☰** menu) and [Excalidraw](https://excalidraw.com) opens, with shapes, arrows, freehand, text and pasted pictures. Everyone on the floor who opens it draws on the same board at once: you see their strokes as they draw, their cursors with their names, and what they have selected. The top of the window shows who's drawing, and so does the hint at the board. Whatever is drawn shows on the whiteboard in the office for everyone walking past, and stays there across restarts. Each floor has its own. Press Esc once to finish what you're doing and again to close it.
- **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists and the project's own pictures. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**.
- **Workers with a personality of their own.** Every worker is dealt a persona from the floor's deck — The Pragmatist, The Skeptic, The Night Owl and nineteen more — seeded by the project and the desk, so the same hire always gets the same character and a fresh hire is someone new. The persona goes into the agent's own brief (Claude Code's system prompt, an instructions file for OpenCode, the first prompt for the rest), so it colours how the worker actually works and answers, and its name shows in the Workers panel.
- **Workers that wander when they've nothing to do.** An idle worker doesn't sit at its desk forever: after a spell it hops down and ambles off to the couch (where it really sits, three to a couch), the beanbags, the TV, the jukebox, the arcade cabinet, the whiteboard, the bookshelf, the elevator lobby or the DJ booth, lingering a while and then heading back. Who goes where, how fast and how long is dealt from a per-worker character — sociability, energy, curiosity, patience, mischief, a walking pace, a tone of voice (dry, warm, hyper, gloomy, formal, chaotic), a quirk (humming, muttering, sighing…) and a taste for some haunts over others — all seeded from the floor, desk and worker id, so no two workers behave or sound alike. Needs for fun, company and calm creep up over time and pull each one toward the spot that answers them. Speech bubbles over their heads carry their lines in their own voice, and walking past one can earn you a hello. Nobody stands inside anybody else: spots are claimed, the floor plan keeps them apart, and a nudge sorts out whatever still crosses. The moment a worker has work again — or needs you — it drops whatever it was doing and goes straight back to its desk.
- **Workers that talk to each other.** Two idle workers near each other stop for a chat: they walk to a point between them, face each other and trade lines, each in its own voice and persona. How often they strike up, who they pick (friends likelier than rivals) and how long it runs all follow their characters, and every chat moves their relationship — repeated chats make friends, clashing tempers make rivals, and the next greeting changes ("there you are" vs "oh. you.").
- **The office mind.** Chats can be written by a small local model instead of the built-in lines: an Ollama server on the office machine (`llama3.2:3b` by default; point it elsewhere with `OFFICE_MIND_URL` and `OFFICE_MIND_MODEL`) writes each pair's whole script while they walk to meet, in character, and afterwards keeps a short note of what mattered — how it felt about the other worker, a topic to bring up next time — in `.agent-office/minds.json`, so workers remember who they talked to and what was said. A worker that finishes a task also writes down what it took away, and anything worth everyone's attention (a bug found, a convention, a warning) goes on a shared list for its floor that any chat can pick up. Each note gets an embedding from Ollama's `nomic-embed-text` (override with `OFFICE_MIND_EMBED`), so when two workers meet the mind recalls the notes most relevant to what they're about — partner, last task — rather than just the latest ones, and older notes count for less as they age. Once a worker's memory piles up it gets distilled down to the few things that matter. Relationships cool on their own too: pairs that stop meeting drift back toward neutral, and the ones that remain are remembered in `minds.json`, so friends and rivals survive restarts. The mind only ever writes a line or two: it never touches code, tools or the repo, and whenever Ollama isn't running the procedural chatter covers silently. On a CPU-only machine a script takes a few seconds to tens of seconds, so early turns may still be procedural; the model stays warm in memory between chats.
- **An office dog.** Every floor has a dog. It naps under the desks of workers who are busy, trots after people for a while, sniffs around and hangs out on the lounge rug. When a worker needs input, it drops everything, runs to that desk and barks until someone opens that worker's terminal, so you can see from across the room who's waiting. It stays there until the worker is answered. Walk up and press **E** to pet it, and everyone sees it wag. Name it under **⚙️** → *Office dog*, and pick its breed (a pup, a corgi, a dachshund, a pug, a shiba or a pomeranian) and its coat there too: they're for everyone on that floor. Until someone picks them, each floor's dog is a breed and coat of its own.
- **Sent home with a box.** A worker you send home (**X** at its desk) doesn't just vanish. Its light goes out, its face falls, and its things go into a cardboard box: a plant, a framed photo, its mug and a rubber duck. Its laptop snaps shut, and it hops off its chair and trudges out with the box in its arms, out the exit door, down the steps and off along the sidewalk. Floors above the bottom one have no exit door, so there it walks out onto the balcony instead, climbs up on the railing and jumps: its parachute pops open and it circles down onto the lot out front, box and all, then walks off along the sidewalk. The desk is free straight away, so you can hire someone new while the last one is still on the way out. In the castle it goes to the dungeon instead and on the space station out of the airlock, and a map of your own can script its own way of seeing workers off ([Sending workers home](maps.md#sending-workers-home)).
- **Voice.** Browser-to-browser WebRTC voice. Volume depends on how close you stand, but people are never fully silent. **V** joins, and once you're in, **V** is push to talk: hold it to talk, and you're muted when you let go. **M** mutes and unmutes. Pick **Push to talk** under **⚙️** to join muted, or keep an open mic. Leave voice from the **☰** menu.
Expand Down
2 changes: 1 addition & 1 deletion src/client/core/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function installLoop(ctx: Ctx, core: CoreState, parts: Pick<Parts, 'stage
const { departures, sendoffs, arrivals } = parts.views;
const court = parts.worlds.court();
if (!core.upTop) {
ctx.world().update(t, dt, [player.pos, ...[...remotes.values()].map((r) => r.person.root.position), ...departures.positions(), ...sendoffs.positions(), ...arrivals.positions(), ...(court?.positions() ?? [])]);
ctx.world().update(t, dt, [player.pos, ...[...remotes.values()].map((r) => r.person.root.position), ...departures.positions(), ...sendoffs.positions(), ...arrivals.positions(), ...(court?.positions() ?? []), ...parts.views.wander.positions()]);
if (ctx.inOffice()) {
office.stack.update(dt, [{ x: player.pos.x, y: player.pos.y, z: player.pos.z, grip: ctx.view.grip() }, ...[...remotes.values()].map((r) => ({ x: r.person.root.position.x, y: r.person.root.position.y, z: r.person.root.position.z, grip: r.grip }))], camera.position);
office.jukebox.update(t, dt, sound.beat());
Expand Down
1 change: 1 addition & 0 deletions src/client/core/maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) {
views.departures.clear();
views.sendoffs.clear();
views.arrivals.clear();
views.wander.clear();
parts.telescope.exit();
ctx.activities.stopAll('map');
parts.walking.stopWalkingTo();
Expand Down
50 changes: 50 additions & 0 deletions src/client/features/workers/crowd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Keeping wanderers from standing inside each other (see features/workers/wander.ts): who has a
// haunt claimed, and the per-tick nudge that sorts out whatever the nav grid collapsed together.
import type * as THREE from 'three';

/** The bits of a wanderer the crowd rules need (wander.ts's Roamer satisfies this). */
export interface RoamerLike {
id: string;
state: string;
/** Where it's off to or lingering, if anywhere. */
spot: { x: number; z: number } | null;
model: { root: { position: THREE.Vector3 } };
}

/** Whether another worker out and about already has `x, z` as its haunt (or is on its way there). */
export function spotTaken(roamers: Iterable<RoamerLike>, self: RoamerLike, x: number, z: number): boolean {
for (const o of roamers) {
if (o === self || !o.spot) continue;
if (o.state !== 'walk' && o.state !== 'linger' && o.state !== 'chat' && o.state !== 'down') continue;
if (Math.hypot(o.spot.x - x, o.spot.z - z) < 1.0) return true;
}
return false;
}

/**
* Nobody stands inside anybody else: whatever the nav grid collapsed together — a shared couch
* place, a chat rendezvous on one cell, paths crossing — gets nudged apart until a body's width.
*/
export function separate(roamers: Iterable<RoamerLike>) {
const out = [...roamers].filter((c) => c.state !== 'seated');
for (let i = 0; i < out.length; i++) {
for (let j = i + 1; j < out.length; j++) {
const a = out[i];
const b = out[j];
const pa = a.model.root.position;
const pb = b.model.root.position;
const dx = pb.x - pa.x;
const dz = pb.z - pa.z;
const d = Math.hypot(dx, dz);
if (d >= 0.55) continue;
const push = (0.55 - d) / 2;
// Dead-centre overlaps pick a stable direction from their ids, so they don't vibrate.
const ux = d > 1e-4 ? dx / d : a.id < b.id ? 1 : -1;
const uz = d > 1e-4 ? dz / d : 0;
pa.x -= ux * push;
pa.z -= uz * push;
pb.x += ux * push;
pb.z += uz * push;
}
}
}
144 changes: 144 additions & 0 deletions src/client/features/workers/mind-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// The client half of the office mind (see server/mind.ts): asks /api/mind for a chat script while
// two wanderers walk to their rendezvous, hands each its lines when the chat starts, and reports
// back what was said so the mind remembers. The procedural lines in wander.ts cover any miss.
import { personaFor } from '../../../shared/personas';
import type { WorkerInfo } from '../../../shared/protocol';
import type { Traits } from '../../../shared/traits';
import { store } from '../../state';

interface MindRoamer {
id: string;
traits: Traits;
desk: { def: { id: string } };
}

interface Script {
aId: string;
a: string[];
b: string[];
}

export class MindChats {
/** Scripts on their way or in hand, per pair. */
private readonly minds = new Map<string, { script?: Script | null }>();
/** Affinity updates waiting for their one batched save. */
private readonly pendingAffinity = new Map<string, number>();
private affinityTimer?: ReturnType<typeof setTimeout>;

constructor(
private worker: (id: string) => WorkerInfo | undefined,
private floor: () => string,
) {}

private key(a: string, b: string) {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}

/** What the mind is told about a worker: who it is, how it sounds, and what it remembers (the server keeps that). */
private actor(c: MindRoamer) {
const w = this.worker(c.id);
const persona = w?.kind === 'agent' ? personaFor(store.project?.dir, c.desk.def.id) : undefined;
return {
id: c.id,
persona: persona?.name,
tone: c.traits.tone,
quirk: c.traits.quirk,
sociability: c.traits.sociability,
energy: c.traits.energy,
mischief: c.traits.mischief,
task: w?.prompt?.slice(0, 200),
memory: [],
};
}

/** Asks for this pair's script; the first caller wins, and a later chat gets a fresh ask. */
ask(a: MindRoamer, b: MindRoamer, turns: number) {
const k = this.key(a.id, b.id);
if (this.minds.has(k)) return;
const rec: { script?: Script | null } = {};
this.minds.set(k, rec);
fetch('/api/mind', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ floor: this.floor(), op: 'chat', turns, a: this.actor(a), b: this.actor(b) }),
})
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
rec.script = j && Array.isArray(j.a) && Array.isArray(j.b) ? { aId: a.id, a: j.a as string[], b: j.b as string[] } : null;
})
.catch(() => {
rec.script = null;
});
}

/** The pair's script once it lands: `aId` says whose lines `a` are. Undefined while it's still being written. */
script(aId: string, bId: string): Script | null | undefined {
return this.minds.get(this.key(aId, bId))?.script;
}

/** A worker finished a task: the mind writes down what it took away (fire-and-forget). */
learn(c: MindRoamer, task: string) {
fetch('/api/mind', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ floor: this.floor(), op: 'learn', a: this.actor(c), task }),
}).catch(() => {});
}

/** Tells the mind what `c` said to `p`, so it remembers them next time (fire-and-forget). */
reflect(c: MindRoamer, p: MindRoamer, said: string[]) {
if (!said.length) return;
fetch('/api/mind', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ floor: this.floor(), op: 'reflect', a: this.actor(c), b: this.actor(p), saidA: said, saidB: [] }),
}).catch(() => {});
}

/** The floor's remembered affinities, once — relationships survive restarts and visitors. */
loadAffinity(): Promise<Record<string, number> | null> {
return fetch('/api/mind', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ floor: this.floor(), op: 'affinity' }),
})
.then((r) => (r.ok ? r.json() : null))
.then((j) => (j && typeof j.affinity === 'object' && j.affinity ? (j.affinity as Record<string, number>) : null))
.catch(() => null);
}

/** Fetch the floor's remembered affinities into `into`: relationships survive restarts and visitors. */
primeAffinity(into: Map<string, number>) {
void this.loadAffinity().then((saved) => {
if (!saved) return;
for (const [k, v] of Object.entries(saved)) {
if (typeof v === 'number' && Number.isFinite(v)) into.set(k, Math.max(-1, Math.min(1, v)));
}
});
}

/** Queue affinity updates; they go up in one request a few seconds later, and go back in the queue if it's lost. */
saveAffinity(pairs: Record<string, number>) {
for (const [k, v] of Object.entries(pairs)) this.pendingAffinity.set(k, v);
if (this.affinityTimer) return;
this.affinityTimer = setTimeout(() => {
this.affinityTimer = undefined;
if (!this.pendingAffinity.size) return;
const pairs = Object.fromEntries(this.pendingAffinity);
this.pendingAffinity.clear();
fetch('/api/mind', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ floor: this.floor(), op: 'affinity', pairs }),
}).catch(() => {
for (const [k, v] of Object.entries(pairs)) this.pendingAffinity.set(k, v);
});
}, 4000);
}

/** A pair's script is done being useful; the next chat asks for a new one. */
forget(aId: string, bId: string) {
setTimeout(() => this.minds.delete(this.key(aId, bId)), 30_000);
}
}

Loading