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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i
- **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.
- **You can't miss who needs you.** A worker that stops to ask you something lights a red beacon over its desk, puts a banner on your screen saying who and what for, and sounds an alarm. One that has finished jumps up and down and dings. Press **N** to go straight to whoever is waiting.
- **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.
- **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its conversation as a chat (with its terminal a tab away, 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`).
- **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard.
Expand Down
2 changes: 1 addition & 1 deletion docs/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Back to the [README](../README.md).

You can also click a nearby desk to interact with it, or click a worker in the Workers panel (**🤖 Workers**, top right) to open its terminal.

On a phone, use the 2D view at `/lite` instead: a terminal there has a row of keys under it (**1** **2** **3**, the arrows, Enter, Tab, Esc, Ctrl+C) and a box to send a prompt. See [Features](features.md).
On a phone, use the 2D view at `/lite` instead: a worker opens on its conversation as a chat (**💬 Chat**), with a box to send a prompt and buttons to answer a permission prompt, and its terminal on the next tab, with a row of keys under it (**1** **2** **3**, the arrows, Enter, Tab, Esc, Ctrl+C). See [Features](features.md).

## In a terminal

Expand Down
3 changes: 2 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ Everything in the office, room by room. Back to the [README](../README.md).
- **Workers act out what they're doing.** A working worker acts out its latest tool call, so you can tell what it's doing from across the room without reading its card. Reading files, it holds up a stack of papers and flips through them. Editing, it hunches over the keys and types flat out. Running tests or a build, it leans back with its hands behind its head. Searching or fetching the web, a globe spins beside its laptop. When its tests or build fail twice in a row, it puts its head in its hands. Waiting on you, it jumps for a couple of seconds, then crosses its arms and taps its foot. When it finishes, it does a little spin and a puff of confetti goes up. Claude Code hooks tell a shell command that runs tests from one that only reads, and they report failures. Codex and OpenCode report only the tool's name, so their shell commands are acted out as plain typing.
- **Next worker that needs you.** Press **N** to go straight to the first worker waiting on someone (it needs input, or it's done and nobody has looked), standing behind it and looking at its laptop. Press **N** again for the next one, and after the last you start over: the ones that need input come first, then the ones that are done, and whoever has waited longest is first among each. A pin at the edge of the screen points the way to each waiting worker you can't see, red for *needs input* and green for *done*, and the top bar counts them (`🙋 2 need you · ✅ 1 done`, in red and pulsing while anyone needs you; click it to do the same as **N**). With nobody waiting on your floor, **N** tells you which floor has someone.
- **Notifications.** You don't have to be in the office tab. The tab title counts the workers waiting on someone, like `(2) my-project · Agent Office`. Allow desktop notifications (the office asks when you first hire a worker, or turn them on under **⚙️**) and a worker that needs you or finishes while you're in another tab or app pops one up (*🙋 Byte needs you*, with what it's asking; that one stays up until it's dealt with). Click it to go straight to that worker: the office comes to the front with you at its desk and its terminal open. For the whole team, paste a Slack or Discord incoming webhook under **⚙️** → *Team notifications* (or start with `--webhook <url>`), and the office posts to that channel when a worker has needed input or been done for a few seconds with nobody at its terminal.
- **The 2D view, for your phone.** Open `/lite` (or **📱 2D view** in the **☰** menu) for the office without the 3D. It lists every worker on the floor, the ones waiting on someone first, each with what it's asking (*🙋 Wants permission: Bash: npm test*) or doing, its branch and PR, and who typed to it last. Tap one for its terminal, with a row of the keys a phone keyboard hasn't got (**1** **2** **3** to answer a question or a permission prompt, the arrows, Enter, Tab, Esc and Ctrl+C) and a box to send it a prompt. **✍️** on a card sends one without opening the terminal. **📌 Issues**, **🔀 PRs**, **📋 Queue** and **✨ New task** open the same windows as in the office, and the floor at the top switches projects, with a button straight to any floor where someone's waiting. The tab title counts who's waiting, and once you allow notifications (**🔔**) a worker that needs you pops one up and buzzes the phone. The 3D office offers the 2D view as it loads on a phone, and on a computer where it draws fewer than 20 frames a second for ten seconds. **Stay in 3D** stops it asking in that browser. A browser that can't draw the 3D office at all (no WebGL) goes straight to the 2D view, and signing in from `/lite` brings you back there. Teammates see you under *In the office* as *📱 on the 2D view*, not standing anywhere in it.
- **The 2D view, for your phone.** Open `/lite` (or **📱 2D view** in the **☰** menu) for the office without the 3D. It lists every worker on the floor, the ones waiting on someone first, each with what it's asking (*🙋 Wants permission: Bash: npm test*) or doing, its branch and PR, and who typed to it last. Tap one for its conversation as a chat (see below), with its terminal on the **💻 Terminal** tab: a row of the keys a phone keyboard hasn't got (**1** **2** **3** to answer a question or a permission prompt, the arrows, Enter, Tab, Esc and Ctrl+C) and a box to send it a prompt. **✍️** on a card sends one without opening the terminal. **📌 Issues**, **🔀 PRs**, **📋 Queue** and **✨ New task** open the same windows as in the office, and the floor at the top switches projects, with a button straight to any floor where someone's waiting. The tab title counts who's waiting, and once you allow notifications (**🔔**) a worker that needs you pops one up and buzzes the phone. The 3D office offers the 2D view as it loads on a phone, and on a computer where it draws fewer than 20 frames a second for ten seconds. **Stay in 3D** stops it asking in that browser. A browser that can't draw the 3D office at all (no WebGL) goes straight to the 2D view, and signing in from `/lite` brings you back there. Teammates see you under *In the office* as *📱 on the 2D view*, not standing anywhere in it.
- **A worker's conversation as a chat.** A Claude Code worker's terminal has a **💬 Chat** tab: what you told it, what it said back (as it wrote it, headings, lists and code included) and each tool it used, as a row that opens to show what ran and what came back, in a page that scrolls like any chat. It's read off the session transcript Claude Code keeps, so it's the conversation itself rather than a picture of the terminal, and it shows a worker's whole history across office restarts; **Earlier…** at the top pages further back. The box at the bottom sends a prompt (🎤 dictates one), **⏹** interrupts, and when the worker wants permission the choices show above the box as buttons (**Yes**, **Always**, **No**), the same keys the terminal takes. A question with its own choices, or a menu, still wants the terminal: the tab is a tap away. On a phone the chat is the tab that opens; on a computer the terminal is, with the chat beside it.
- **Task cards.** A card over each worker's head names what it's on ("Fix Login Redirect") and says in one line what it's doing right now. For Claude workers, Claude Haiku writes it from prompts and recent tools through the `claude` CLI. OpenCode, Codex, Grok, Muse, DeepSeek Harness and Cursor use local task summaries and never invoke Claude to name their tasks.
- **Survives restarts.** Workers are saved to disk with their agent choice and session ID, and their terminals run in a small process of their own. When the server restarts (a dev-server reload, a `kill` of its process), the agents keep working through it, and the new server picks every terminal back up, scrollback and all. Open terminals reconnect by themselves. Ctrl+C stops the workers; the next start wakes them back up in their sessions, and **R** resumes the Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor session of one that exited. DeepSeek Harness workers are the exception to keeping working through a restart: their ACP session is a child of the office process, so it stops with the office and comes back *offline*, resumable with **R**. When a restart does take a worker's terminal with it (the machine rebooted, the terminal process died or was replaced by a newer one), a worker that was in the middle of something is resumed with *continue*, so it carries on by itself. The chat and each worker's last 3,000 lines of terminal are kept too, so after a restart (or a crash) you can still scroll back through what the workers printed and what everyone said.
- **Search.** Press **/** (or **🔎 Search** in the **☰** menu) to search the chat and every worker's terminal at once, including what they showed before the office restarted. Click a terminal line to open that terminal scrolled to it, with the line lit up.
Expand Down
22 changes: 19 additions & 3 deletions src/client/ui/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { engineLabel, providerUsageNote, providerUsageState, providerWaitingLabe
import { naturalKey } from './termkeys';
import { termTabs } from './termtabs';
import { dictateField, dictation } from './dictate';
import { transcriptPane } from './transcript';

/** A line to scroll to once the terminal has loaded: a search hit (see search.ts). */
export interface TerminalFind {
Expand Down Expand Up @@ -134,7 +135,19 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void,
const sayBtn = h('button.btn.primary', { type: 'submit' }, 'Send');
const sayForm = h('form.term-say', {}, dictateField(say), sayBtn);
const keypad = opts.keypad ? h('div.term-keypad', {}, keys, sayForm) : null;
const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus() });
// A Claude Code worker's conversation as a chat, a tab beside the terminal: the one a phone opens on.
const chat =
info.kind === 'agent' && resolvedProvider(info.provider, store.project) === 'claude'
? transcriptPane(net, workerId, {
key: (data) => {
sendSize(true);
sayTyping();
term.input(data);
},
toTerminal: () => tabs.show('main'),
})
: null;
const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus(), chat: chat ? { el: chat.el, onShow: chat.show, onHide: chat.hide } : undefined, start: chat && opts.keypad ? 'chat' : 'main' });
// What you say is typed in at the terminal's cursor, as a paste, for you to read over and send (see dictate.ts).
const mic = dictation(
{
Expand All @@ -149,7 +162,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void,
);
host.append(mic.live);
// The keypad has an Esc of its own, and a 🎤 on its prompt box.
const el = h('div.modal.term', { role: 'dialog', 'aria-label': `${info.name} terminal` }, h('header', {}, dot, title, pill, cost, viewers, typed, modelsBtn, keypad ? null : mic.button, keypad ? null : escBtn, onChanges ? changesBtn : null, hideBtn, closeBtn), tabs.bar, host, tabs.pages, keypad);
const el = h('div.modal.term', { role: 'dialog', 'aria-label': `${info.name} terminal` }, h('header', {}, dot, title, pill, cost, viewers, typed, modelsBtn, keypad ? null : mic.button, keypad ? null : escBtn, onChanges ? changesBtn : null, hideBtn, closeBtn), tabs.bar, host, chat?.el ?? null, tabs.pages, keypad);

const term = new Terminal({
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
Expand Down Expand Up @@ -262,6 +275,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void,
cost.textContent = w.kind !== 'agent' ? '' : usageState === 'tracked' && w.usage ? usageLabel(w.usage, workerProvider) : waiting ? waiting : usageState === 'untracked' ? 'usage untracked' : '';
cost.title = w.kind === 'agent' && w.usage ? usageTitle(w.usage, workerProvider) : w.kind === 'agent' ? providerUsageNote(workerProvider!) : '';
renderPresence(w);
chat?.refresh(w);
const openCode = w.kind === 'agent' && resolvedProvider(w.provider, store.project) === 'opencode';
modelsBtn.classList.toggle('hidden', !openCode);
modelsBtn.toggleAttribute('disabled', !openCode || !ready || isAsleep(w.status));
Expand Down Expand Up @@ -307,7 +321,8 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void,
let pendingFind = find;

const onMsg = (msg: ServerMsg) => {
if (msg.t === 'term.data' && msg.workerId === workerId) term.write(msg.data);
if (msg.t === 'transcript.items' || msg.t === 'transcript.none') chat?.onMsg(msg);
else if (msg.t === 'term.data' && msg.workerId === workerId) term.write(msg.data);
else if (msg.t === 'term.typing' && msg.workerId === workerId) {
typing.set(msg.id, Date.now() + TYPING_SHOWS_MS);
const w = store.workers.get(workerId);
Expand Down Expand Up @@ -346,6 +361,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void,
clearInterval(typingTimer);
ro.disconnect();
mic.drop();
chat?.dispose();
net.send({ t: 'worker.detach', workerId });
term.dispose();
if (current?.modal === modal) current = null;
Expand Down
27 changes: 18 additions & 9 deletions src/client/ui/termtabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ export interface TermTabsOptions {
keypad: HTMLElement | null;
/** Back on the terminal's tab. */
focusTerm(): void;
/** The worker's chat (ui/transcript.ts), a tab of its own beside the terminal, for a worker that has one. */
chat?: { el: HTMLElement; onShow(): void; onHide(): void };
/** The tab open at first (the terminal, unless said otherwise). */
start?: 'main' | 'chat';
}

/** The tab bar (above the terminal) and where the pages show (beside it). */
export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLElement; pages: HTMLElement } {
/** The tab bar (above the terminal) and where the pages show (beside it); `show('main')` is back to the terminal. */
export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLElement; pages: HTMLElement; show(id: 'main' | 'chat'): void } {
const tabsBar = h('div.term-tabs', { role: 'tablist', 'aria-label': 'Tabs' });
const tabName = h('input', { type: 'text', placeholder: 'Tab name (e.g. ChatGPT)', 'aria-label': 'Tab name', maxlength: '40', autocomplete: 'off' }) as HTMLInputElement;
const tabUrl = h('input', { type: 'url', placeholder: 'https://…', 'aria-label': 'Web page address', autocomplete: 'off' }) as HTMLInputElement;
Expand All @@ -44,12 +48,13 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl
const tabs = tabsByWorker.get(workerId) ?? [];
tabsByWorker.set(workerId, tabs);
const frames = new Map<string, HTMLIFrameElement>();
let active = 'main';
let active = opts.chat && opts.start === 'chat' ? 'chat' : 'main';

const render = () => {
const mainTab = h('div.term-tab', { class: active === 'main' ? 'on' : '' }, h('button.term-tab-label', { type: 'button', role: 'tab', 'aria-selected': String(active === 'main'), onclick: () => show('main') }, '💻 Terminal'));
const chatTab = opts.chat ? h('div.term-tab', { class: active === 'chat' ? 'on' : '' }, h('button.term-tab-label', { type: 'button', role: 'tab', 'aria-selected': String(active === 'chat'), onclick: () => show('chat') }, '💬 Chat')) : null;
tabsBar.replaceChildren(
mainTab,
...(chatTab ? [chatTab, mainTab] : [mainTab]),
...tabs.map((t) =>
h(
'div.term-tab',
Expand All @@ -74,15 +79,19 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl
openOut.classList.toggle('hidden', !page);
if (page) openOut.href = page.url;
};
const show = (id: string) => {
const show = (id: string, focus = true) => {
const wasChat = active === 'chat';
active = id;
const onMain = id === 'main';
const onChat = id === 'chat';
opts.host.classList.toggle('hidden', !onMain);
pages.classList.toggle('hidden', onMain);
pages.classList.toggle('hidden', onMain || onChat);
opts.keypad?.classList.toggle('hidden', !onMain);
for (const [tid, frame] of frames) frame.classList.toggle('hidden', tid !== id);
render();
if (onMain) opts.focusTerm();
if (onChat) opts.chat?.onShow();
else if (wasChat) opts.chat?.onHide();
if (onMain && focus) opts.focusTerm();
};
const close = (id: string) => {
const i = tabs.findIndex((t) => t.id === id);
Expand All @@ -99,7 +108,7 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl
pages.append(frame);
};
for (const t of tabs) frameFor(t);
render();
show(active, false);

addBtn.addEventListener('click', () => {
form.classList.remove('hidden');
Expand Down Expand Up @@ -129,5 +138,5 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl
closeForm();
show(tab.id);
});
return { bar, pages };
return { bar, pages, show };
}
Loading