diff --git a/README.md b/README.md index fac9441a5..67a7213af 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **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. +- **Subagents and visitors.** A Claude Code worker's subagents (the Agent tool, a workflow's agents) stand round its chair while they run, each with what it's on. Claude Code sessions the office didn't start (the Claude desktop app, a terminal, an IDE) wait in the lobby by the elevator of the floor whose repository they work in, with their own subagents round them, only to look at: the office reads their transcripts under `~/.claude/projects` and never writes to them or drives them (`--no-visitors` turns them off). - **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)). diff --git a/docs/configuration.md b/docs/configuration.md index 611e23f0d..2d1da99e2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,6 +36,7 @@ agent-office [dir] [options] --city Put the office in a real city: its sun and live weather (open-meteo.com) --weather Pin the weather: clear, cloudy, rain, storm, snow or fog --real-time-sky Start the sky on the real clock, not a day an hour (env AGENT_OFFICE_SKY_CLOCK=real; โš™๏ธ Settings can switch it) + --no-visitors Don't show the Claude Code sessions the office didn't start as visitors (env AGENT_OFFICE_VISITORS=0) agent-office setup [--projects ] [--project ]... [--home ] diff --git a/docs/features.md b/docs/features.md index f320d9b99..be2b5782b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -21,6 +21,8 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Minesweeper on the boss's monitor.** In the boss's chair, **E** plays Minesweeper on the monitor: the camera moves in close and the board sits right on the screen. Click to dig, right-click (or Shift-click) to flag, and click a number to dig around it once its mines are flagged. The first click is never a mine. **Stop playing** leaves your game up on the monitor, with its clock paused, until you come back. Walk off to get up. - **Pick your character.** The first time you join, a character select screen lets you choose your name, skin tone, hair style, hair color and shirt, with a spinning preview. No name in mind? Leave it blank (or skip the screen) and you go by the made-up one shown in the box, like *Sunny Otter*, or press **๐ŸŽฒ** for another. The office remembers your name and look in that browser, so you only pick once. Change it any time from **โš™๏ธ** or by clicking your name under *In the office* (turn on **๐Ÿ‘ฅ People** in the **โ˜ฐ** menu). - **Hire workers.** Walk up to an empty desk and press **E** to choose Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor, or press **P** to write a task first. A little worker sits down, a laptop opens, and the agent's live screen appears on it. Choose the agent for each queue task too. +- **Subagents at the desk.** When a Claude Code worker hands part of its work to subagents (the Agent tool, or the agents of a workflow it runs), each one stands round its chair while it runs: a smaller worker without a headset, turned to its boss, with a card saying what kind it is (*๐Ÿ”Ž EXPLORE*, *๐Ÿค– AGENT*, a workflow's phase), what it was asked to do and its latest tool call. It pops in at `SubagentStart` and goes at `SubagentStop`; past six round one desk, a sign says how many more there are. One stopped mid-turn (Esc) without a word goes after 15 minutes of silence, and a worker that's asleep has none. +- **Visitors.** Claude Code sessions the office didn't start, in the Claude desktop app, a terminal or an IDE, show up as visitors: they wait in the lobby between the elevator and the lounge on the floor whose repository they work in (by their folder, or their folder's `origin`), or on the building's first floor with a ๐Ÿ›Ž๏ธ when no floor's repository is theirs. Each has a card with its name in the app (or its first prompt), its folder and branch, and its latest tool call, its light shows whether it's working, done or waiting on you there (then it hops), and its subagents and workflow agents stand round it. Aim at one for where it runs. They're only to look at: the office reads the transcripts Claude Code keeps under `~/.claude/projects` (`$CLAUDE_CONFIG_DIR/projects`) every few seconds while someone's in the office, never writes to them, and can't type into those sessions. A session leaves 10 minutes after its last write, unless its subagents are still at work. The office's own workers never show up twice. `--no-visitors` (or `AGENT_OFFICE_VISITORS=0`) turns them off. - **Signs over the desks.** Press **L** at any desk, empty or not, to hang a big sign from the ceiling over it: *Operations*, *Code cleanup*, *Frontend*, whatever that desk is for. Type up to 32 characters (or pick one of the ideas), choose one of seven colors and press **Enter**. It hangs over the far edge of the desk, facing the chair, so it reads over the shoulder of whoever sits there; while the desk back to back with it has no sign of its own, it says the same on the back, so you can read it from across the room either way. Everyone on the floor sees it, the hint at the desk names it, and it stays up across restarts. **L** again changes it or takes it down. Each floor has its own. - **Room to grow.** When a floor needs more desks, the north wall past the gong comes down. Walk up to the yellow **๐Ÿšง Room to grow** sign on it and press **E**, then **๐Ÿ”จ Knock through**: the wall goes, and a back office opens behind it with a pair of desks, a rug, a lamp and a window, in a puff of confetti. Knock through once more and it goes back another row, for two more desks (two rows is as far as it goes before the street behind the building). New workers, and the task queue, take its desks once the room's are full, before any bean bag comes out. The sign moves to the back wall, where **E** can also wall the last row back up, once nobody's working there. Each floor is built out on its own, and from outside you see it: the back office sticks out of that storey, on posts down to the lawn. - **Bean bags for the overflow.** There are 16 desks, and up to 4 more in the back office. Once they're all taken, a bean bag comes out on the floor with a low lap desk in front of it. Hire a worker there the same way as at a desk, and the next bean bag comes out, up to 12 around the room. The task queue seats workers on them too. A bean bag stays out while a worker sits on it, and the empty one goes away again as soon as a desk frees up. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 471783328..1d67b6c57 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -14,7 +14,9 @@ browser โ”€โ”€HTTPS/WSSโ”€โ”€โ–ถ agent-office (Node) โ””โ”€ WebRTC signaling relay (voice + screen share are peer-to-peer) ``` -- **Status.** Claude workers start with `--settings .agent-office/claude-hooks.json`. That file adds hooks (`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `Stop`, `SessionStart`) which `curl` a server bound to `127.0.0.1`. The hooks merge with your own Claude settings; they don't replace them. OSC 9;4 progress sequences in Claude terminals also count, which catches an Esc-cancel. OpenCode workers use a generated plugin to forward session, permission and question events to the same loopback server, with a separate endpoint. Codex uses a generated command helper and its own authenticated endpoint. The server reads only token records from the explicitly linked root rollout; approval settings are preserved. DeepSeek Harness workers report through the ACP session itself: `session/prompt` in flight is *working*, an outstanding `session/request_permission` is *needs input*, and a settled turn is *done*, with no hooks and no polling. +- **Status.** Claude workers start with `--settings .agent-office/claude-hooks.json`. That file adds hooks (`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `Notification`, `Stop`, `SessionStart`, `SubagentStart`, `SubagentStop`) which `curl` a server bound to `127.0.0.1`. The hooks merge with your own Claude settings; they don't replace them. OSC 9;4 progress sequences in Claude terminals also count, which catches an Esc-cancel. OpenCode workers use a generated plugin to forward session, permission and question events to the same loopback server, with a separate endpoint. Codex uses a generated command helper and its own authenticated endpoint. The server reads only token records from the explicitly linked root rollout; approval settings are preserved. DeepSeek Harness workers report through the ACP session itself: `session/prompt` in flight is *working*, an outstanding `session/request_permission` is *needs input*, and a settled turn is *done*, with no hooks and no polling. +- **Subagents.** `SubagentStart` adds a subagent to the worker (`WorkerInfo.subagents`, `src/server/subagents.ts`) and `SubagentStop` takes it off; a subagent's own hook events carry its `agent_id`, so its `PreToolUse` says what it's doing, and one the office only hears of that way (it started before the office did) is taken in then. What it was asked to do is read from the `agent-.meta.json` Claude Code writes beside its transcript (`/subagents/`, or a workflow run's folder under `subagents/workflows/`). A new session (`SessionStart`) has none, and one not heard from in 15 minutes is dropped. They aren't saved in `workers.json`. The page stands them round the worker's chair (`src/client/features/subagents/`). Their spend was already in the worker's: the cost reader takes in `/subagents/*.jsonl` too. +- **Visitors.** `src/server/visitors/` looks over Claude Code's projects folder (`~/.claude/projects`, or `$CLAUDE_CONFIG_DIR/projects`) every 4 seconds while anyone is in the office. It stats each session's transcript (`/.jsonl`; ones untouched for 6 hours only once a minute), and reads only the first 64 KB (where it runs, its first prompt) and the last 256 KB (its title, its latest messages) of the ones written in the last 10 minutes or with a subagent written to in the last 3, again only when the file has changed. Status comes from the last message: a tool call is *working* (`AskUserQuestion` and `ExitPlanMode` are *waiting on you*), a finished turn or an Esc is *done*. Subagents and workflow agents are the `agent-*.jsonl` in `/subagents/` (and `subagents/workflows//`) written in the last 3 minutes whose meta file doesn't say `finished`. Sessions whose id is, or ever was, one of the office's workers' are skipped, and so are ones in a floor's `.agent-office`. A visitor goes to the floor whose checkout its folder is in, else whose repository its folder's `origin` is (asked of `git` once every 10 minutes), else to the first floor, marked as in the lobby. Each floor gets a `visitors` message when its list changes, and its list is part of `FloorView`. Nothing under that folder is ever opened for writing, and nothing is sent to those sessions: the files are opened read-only and only stat'ed, listed and read. - **Shared shells.** Press **B** at an empty desk to open a plain login shell for dev servers, git or tests. It's shared the same way as a Claude terminal. - **Isolated branches.** When you hire with a task, you can tick *own git worktree*. The worker then gets its own `office/` branch under `.agent-office/worktrees/`, so parallel workers never share a checkout. The branch starts from what the office's branch is on GitHub: just before cutting it (a hire, a queue task, a meeting), the office runs `git fetch origin ` (`src/server/worktrees.ts`; a burst of hires shares one fetch) and branches from `origin/`, so pull requests merged since anyone pulled are in it. It branches from the local `HEAD` instead when that already has all of it (it's ahead with commits not pushed yet), or when there's no origin or the fetch fails. When both have moved on, it starts from origin's and a toast says how many local commits it left out. The project's own checkout is never moved, and the Changes window diffs against whichever of the two is newer. When you send that worker home you choose whether to keep the worktree and branch, delete the worktree, or delete both, and the office warns you first about uncommitted changes or commits no remote has. `agent-office prune` clears out whatever was kept, once it is safe. A worker whose folder (its worktree, or its workspace) is gone is never started there: `checkLost` in `src/server/workers/worktree.ts` looks before every start and every 10 seconds, and sets `WorkerInfo.lost` with where its branch still is (`here`, only on `origin`, or `gone`), with no toast. `worker.rebuild` (`all` for every lost worker on the floor) runs `git worktree prune` and `git worktree add` at the same path, on the branch, else `-b ` from `origin/`, else from its base commit (`Worktrees.restore`), rewrites a workspace's brief, and restarts everyone who worked in that folder (a meeting's table too) with `--resume`; one whose process was still running in the deleted folder is stopped and resumed there. A folder put back by hand clears the mark by itself. With *Workers whose pull request merged* set to go home by themselves (`src/server/leave-on-merge.ts`, saved in `.agent-office/leave-on-merge.json`), each floor looks again whenever the PR list comes back, a queue task's PR is linked or a worker changes, and uses the same call as the purple bubble (`workerPr` in `src/shared/status.ts`). A worker goes with the office's usual check for work its worktree would lose, except that commits up to the merged PR's head count as delivered, even once GitHub has deleted the branch. - **Pull requests.** **O** at a worktree worker's desk runs `git push -u origin office/` in its worktree, then `gh pr create` with the task as the body and the commits listed. The base is the branch the office was on when the worktree was cut, if that branch is on the remote. The PR number is saved with the worker, so the board can point back at the desk. diff --git a/src/client/features/subagents/index.ts b/src/client/features/subagents/index.ts new file mode 100644 index 000000000..84088fb34 --- /dev/null +++ b/src/client/features/subagents/index.ts @@ -0,0 +1,93 @@ +/** + * A worker's subagents (WorkerInfo.subagents, from its Claude Code hooks, see server/subagents.ts): + * each one stands behind its worker's chair while it runs, with its kind, what it was asked to do and + * its latest tool call on a card over its head, and goes when it's done. Past SHOWN of them, a sign + * says how many more there are. + */ +import * as THREE from 'three'; +import { liveSubagents, type WorkerStatus } from '../../../shared/protocol'; +import type { Ctx } from '../../core/context'; +import { store } from '../../state'; +import type { DeskView } from '../../world/types'; +import { SubagentCrowd } from '../../world/character'; + +/** A worker whose process is running: one asleep or gone has nobody working for it. */ +const RUNNING = new Set(['starting', 'idle', 'working', 'needs_input', 'done']); +/** How many stand round a desk; the rest are a count. */ +const SHOWN = 6; +/** How big they are next to a worker. */ +const SIZE = 0.6; + +/** + * Where the i-th stands, in the seat's own frame (the worker faces +z, its desk): behind the chair, + * the first two at its shoulders and the rest in a row further back, each turned to its worker. + */ +const SPOTS: readonly (readonly [number, number])[] = [ + [-0.62, -0.7], + [0.62, -0.7], + [-1.15, -1.2], + [0, -1.3], + [1.15, -1.2], + [-0.6, -1.85], + [0.6, -1.85], +]; +const spot = (i: number) => { + const [x, z] = SPOTS[Math.min(i, SPOTS.length - 1)]; + return [x, z, Math.atan2(-x, -z)] as const; +}; + +interface Standing { + desk: DeskView; + holder: THREE.Object3D; + crowd: SubagentCrowd; +} + +/** Follows the workers, and keeps each one's subagents round its desk (its tick: 'others'). */ +export function installSubagents(ctx: Ctx) { + const standing = new Map(); + + function drop(id: string) { + const s = standing.get(id); + if (!s) return; + s.crowd.dispose(); + s.holder.removeFromParent(); + standing.delete(id); + } + + function sync() { + const world = ctx.world(); + const now = store.officeNow(); + for (const w of store.workers.values()) { + const list = RUNNING.has(w.status) ? liveSubagents(w.subagents, now) : []; + const desk = world.desks.get(w.deskId); + let s = standing.get(w.id); + if (s && (s.desk !== desk || !list.length)) { + drop(w.id); + s = undefined; + } + if (!desk || !list.length) continue; + if (!s) { + // On the floor under the seat, turned the way its worker faces. + const holder = new THREE.Object3D(); + holder.position.set(desk.seatAnchor.position.x, 0, desk.seatAnchor.position.z); + holder.rotation.y = desk.seatAnchor.rotation.y; + desk.group.add(holder); + s = { desk, holder, crowd: new SubagentCrowd(holder, spot, SIZE) }; + standing.set(w.id, s); + } + s.crowd.sync(list.slice(0, SHOWN), list.length - SHOWN); + } + for (const id of standing.keys()) if (!store.workers.has(id)) drop(id); + } + + store.on('workers', sync); + /** When (performance.now()) the subagents were last looked over for any gone quiet without a word. */ + let checkedAt = 0; + ctx.ticks.add('others', ({ dt, t, now }) => { + if (now - checkedAt > 30_000) { + checkedAt = now; + if (standing.size) sync(); + } + for (const s of standing.values()) s.crowd.update(dt, t); + }); +} diff --git a/src/client/features/visitors/index.ts b/src/client/features/visitors/index.ts new file mode 100644 index 000000000..72594c8b2 --- /dev/null +++ b/src/client/features/visitors/index.ts @@ -0,0 +1,115 @@ +/** + * Visitors: Claude Code sessions the office didn't start (the Claude desktop app, a terminal, an + * IDE), waiting by the elevator of the floor whose repository they work in (or of the building's + * first floor, its lobby, when no floor's is theirs), each with a card saying what it's called and + * what it's on, and its subagents round it. Only to look at: nothing here can drive them, and the + * office never writes to their files (see server/visitors/). + */ +import * as THREE from 'three'; +import type { Visitor, VisitorApp } from '../../../shared/protocol'; +import type { Ctx } from '../../core/context'; +import { aside, hintTitle } from '../../core/hint'; +import { noOutline } from '../../core/outline'; +import { store } from '../../state'; +import { SubagentCrowd, Worker, subagentColor } from '../../world/character'; +import { VISITOR_SPOTS, subagentSpot } from './spots'; + +// The kinds of thing you can use that this defines (see InteractKinds in world/types.ts). +declare module '../../world/types' { + interface InteractKinds { + visitor: true; + } +} + +/** How big a visitor's subagents are, next to it. */ +const SUBAGENT_SIZE = 0.5; + +const APP: Record = { + desktop: '๐Ÿ–ฅ๏ธ Claude app', + cli: 'โŒจ๏ธ Terminal', + ide: '๐Ÿงฉ IDE', + other: '๐Ÿค– Claude Code', +}; + +interface VisitorView { + model: Worker; + holder: THREE.Group; + crowd: SubagentCrowd; + status: string; +} + +/** What a visitor's card says: its title, and where it is and what it's doing. */ +export function visitorCard(v: Visitor): { name: string; summary: string } { + const where = `${v.lobby ? '๐Ÿ›Ž๏ธ ' : ''}${v.folder}${v.branch ? ` (${v.branch})` : ''}`; + return { name: v.title, summary: v.activity ? `${where} ยท ${v.activity}` : where }; +} + +/** Follows the floor's visitors (the 'visitors' topic), and their tick ('others'). */ +export function installVisitors(ctx: Ctx) { + const views = new Map(); + const group = new THREE.Group(); + ctx.scene.add(group); + noOutline(group); + + function drop(id: string) { + const v = views.get(id); + if (!v) return; + v.crowd.dispose(); + v.holder.removeFromParent(); + v.model.dispose(); + views.delete(id); + } + + function sync() { + const list = store.visitors.visitors.slice(0, VISITOR_SPOTS.length); + const ids = new Set(list.map((v) => v.id)); + for (const id of [...views.keys()]) if (!ids.has(id)) drop(id); + list.forEach((v, i) => { + let view = views.get(v.id); + if (!view) { + const holder = new THREE.Group(); + const model = new Worker(APP[v.app], subagentColor(v.id)); + holder.add(model.root); + group.add(holder); + view = { model, holder, crowd: new SubagentCrowd(holder, subagentSpot, SUBAGENT_SIZE), status: '' }; + views.set(v.id, view); + } + const [x, z, rotY] = VISITOR_SPOTS[i]; + view.holder.position.set(x, 0, z); + view.holder.rotation.y = rotY; + if (view.status !== v.status) { + view.status = v.status; + // Waiting on you in its own app: it hops, the way a worker that needs you does. + view.model.setStatus(v.status, v.status === 'needs_input'); + } + view.model.setTask(visitorCard(v)); + view.crowd.sync(v.subagents, v.subagentCount - v.subagents.length); + }); + group.visible = ctx.inOffice(); + } + store.on('visitors', sync); + + ctx.ticks.add('others', ({ dt, t }) => { + if (!group.visible) return; + for (const v of views.values()) { + v.model.update(dt, t); + v.crowd.update(dt, t); + } + }); + + // Aimed at, a visitor says who it is; there's nothing to press: it's someone else's session. + ctx.usables.add({ + usable: () => (group.visible ? store.visitors.visitors.slice(0, VISITOR_SPOTS.length).map((v, i) => ({ kind: 'visitor' as const, x: VISITOR_SPOTS[i][0], z: VISITOR_SPOTS[i][1], radius: 0.6, seatId: v.id })) : []), + pickable: () => group, + }); + ctx.interactions.define('visitor', { + reach: 4, + hint: (it) => { + const v = store.visitors.visitors.find((x) => x.id === it.seatId); + if (!v) return { k: 'visitor', parts: [hintTitle('๐Ÿ‘‹ A visitor')] }; + const subs = v.subagentCount ? ` ยท ${v.subagentCount} subagent${v.subagentCount === 1 ? '' : 's'}` : ''; + return { k: `visitor|${v.id}|${v.status}|${v.subagentCount}`, parts: [hintTitle(`๐Ÿ‘‹ ${v.title}`), aside(`${APP[v.app]} ยท ${v.folder}${subs} ยท only to look at`)] }; + }, + use: () => {}, + }); +} diff --git a/src/client/features/visitors/spots.ts b/src/client/features/visitors/spots.ts new file mode 100644 index 000000000..d5688bdfb --- /dev/null +++ b/src/client/features/visitors/spots.ts @@ -0,0 +1,27 @@ +// Where visitors wait on a floor, and where their subagents stand round them. + +/** + * The lobby between the elevator and the lounge, in the order they come: x, z, and the way each + * faces (into the room). Clear of the elevator's doors, the whiteboard (which would hide them from the + * room), the gong and the couch. + */ +export const VISITOR_SPOTS: readonly (readonly [x: number, z: number, rotY: number])[] = [ + [11.6, -5.6, -0.35], + [14.0, -5.6, -0.35], + [9.4, -5.8, -0.35], + [16.3, -5.6, -0.5], + [12.8, -8.4, -0.25], + [10.4, -8.6, -0.25], + [15.2, -8.4, -0.35], + [7.6, -3.2, -0.2], +]; + +/** Round a visitor, in its own frame (it faces +z): at its sides first, then further round. */ +const RING: readonly number[] = [70, -70, 115, -115, 35, -35, 150, -150].map((d) => (d * Math.PI) / 180); +const RADIUS = 1.0; + +export function subagentSpot(i: number): readonly [x: number, z: number, rotY: number] { + const a = RING[i % RING.length]; + const r = RADIUS + Math.floor(i / RING.length) * 0.6; + return [Math.sin(a) * r, Math.cos(a) * r, 0]; +} diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..37d636277 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -55,9 +55,11 @@ import { installPeers } from './features/peers'; import { installRooftop } from './features/rooftop'; import { installSeating } from './features/seating'; import { installSmoke } from './features/smoke'; +import { installSubagents } from './features/subagents'; import { installLamplight } from './features/lamplight'; import { installTelescope } from './features/telescope'; import { installTv } from './features/tv'; +import { installVisitors } from './features/visitors'; import { installVoice } from './features/voice'; import { installWaiting } from './features/waiting'; import { installWalking } from './features/walking'; @@ -169,6 +171,8 @@ parts.cards = installCarrying(ctx, { }); parts.seating = installSeating(ctx, { shares: () => parts.talk.currentShares(), watchShare: () => parts.talk.watchShare(), arcade: parts.arcade, showBar: parts.bar.showBar, usable: () => parts.pointer.usable() }); installGong(ctx, { burstOver: parts.views.burstOver, workerViews: parts.views.workerViews, court: () => parts.worlds.court(), idleAgents: () => parts.worlds.idleAgents() }); +installSubagents(ctx); +installVisitors(ctx); parts.hintbar = installHintBar(ctx, core, parts); parts.emotes = installEmotes(ctx, { personOf }); diff --git a/src/client/state/slices/index.ts b/src/client/state/slices/index.ts index 06bc121ea..e276b8d60 100644 --- a/src/client/state/slices/index.ts +++ b/src/client/state/slices/index.ts @@ -31,6 +31,7 @@ import { team } from './team'; import { theme } from './theme'; import { upgrade } from './upgrade'; import { usage } from './usage'; +import { visitors } from './visitors'; import { whiteboard } from './whiteboard'; export const SLICES: readonly Slice[] = [ @@ -61,4 +62,5 @@ export const SLICES: readonly Slice[] = [ team, accounts, signins, + visitors, ]; diff --git a/src/client/state/slices/visitors.ts b/src/client/state/slices/visitors.ts new file mode 100644 index 000000000..4dc904211 --- /dev/null +++ b/src/client/state/slices/visitors.ts @@ -0,0 +1,28 @@ +import type { VisitorsState } from '../../../shared/protocol'; +import type { Slice } from '../store'; + +declare module '../store' { + interface Store { + /** Claude Code sessions the office didn't start, visiting your floor (see features/visitors). */ + visitors: VisitorsState; + } + interface Topics { + visitors: true; + } +} + +export const visitors: Slice = { + init(s) { + s.visitors = { visitors: [] }; + }, + on: { + visitors(s, m) { + s.visitors = m.state; + return ['visitors']; + }, + }, + enter(s, v) { + s.visitors = v.visitors ?? { visitors: [] }; + return ['visitors']; + }, +}; diff --git a/src/client/world/character/index.ts b/src/client/world/character/index.ts index c7b73b681..57e0d0969 100644 --- a/src/client/world/character/index.ts +++ b/src/client/world/character/index.ts @@ -6,3 +6,4 @@ export type { Stage } from './worker-dance'; export { BACKSWING_TIME, IMPACT } from './person-golf'; export { EXHALE_AT, REACH_TIME, SMOKE_CYCLE, dragCurve, emoteEnvelope, popCurve, reachCurve } from './curves'; export { boxOfStuff, cigarette, coffeeMug, drinkGlass, putDownGlass } from './props'; +export { SubagentCrowd, SubagentFigure, subagentColor, subagentKind } from './subagent'; diff --git a/src/client/world/character/subagent.ts b/src/client/world/character/subagent.ts new file mode 100644 index 000000000..62941bd58 --- /dev/null +++ b/src/client/world/character/subagent.ts @@ -0,0 +1,167 @@ +import * as THREE from 'three'; +import type { Subagent } from '../../../shared/protocol'; +import { cardSprite, disposeSprite, mesh, textSprite, toon, toonUnique } from '../toon'; + +/** A subagent's colour: its own, picked from its id, so the same one keeps its colour. */ +const COLORS = ['#ffadad', '#ffd6a5', '#fdffb6', '#caffbf', '#9bf6ff', '#a0c4ff', '#bdb2ff', '#ffc6ff']; + +export function subagentColor(id: string): string { + let h = 0; + for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) | 0; + return COLORS[Math.abs(h) % COLORS.length]; +} + +/** + * A subagent: a small helper of a worker or a visitor, standing by it while it runs, with what it's + * on over its head. Built like the Worker (a bean with a status light), smaller and without the + * headset, so it never passes for one. Forward is +z. + */ +export class SubagentFigure { + readonly root = new THREE.Group(); + private body = new THREE.Group(); + private tag: THREE.Sprite | null = null; + private tagKey = ''; + private arms: THREE.Object3D[] = []; + private eyes: THREE.Mesh[] = []; + private phase = Math.random() * Math.PI * 2; + private popT = 0; + private blinkAt = 1 + Math.random() * 3; + + constructor(color: string) { + const skin = toonUnique(color); + const white = toon('#ffffff'); + const ink = toon('#1d1d1d'); + this.root.add(this.body); + this.body.add(mesh(new THREE.CapsuleGeometry(0.28, 0.3, 8, 16), skin, 0, 0.55, 0)); + for (const sx of [-1, 1]) { + const eye = mesh(new THREE.SphereGeometry(0.09, 12, 10), white, sx * 0.11, 0.7, 0.23, false); + eye.scale.z = 0.6; + const pupil = mesh(new THREE.SphereGeometry(0.045, 10, 8), ink, sx * 0.11, 0.7, 0.29, false); + this.body.add(eye, pupil); + this.eyes.push(eye, pupil); + const arm = new THREE.Group(); + arm.position.set(sx * 0.3, 0.55, 0.05); + arm.add(mesh(new THREE.CapsuleGeometry(0.055, 0.16, 4, 8), skin, 0, -0.12, 0)); + this.body.add(arm); + this.arms.push(arm); + this.body.add(mesh(new THREE.CapsuleGeometry(0.06, 0.1, 4, 8), skin, sx * 0.12, 0.2, 0.05)); + } + // A short antenna with a working light: a subagent only exists while it works. + this.body.add(mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.14, 6), ink, 0, 1.0, 0, false)); + const bulb = toonUnique('#ffd166'); + bulb.emissive = new THREE.Color('#ffd166').multiplyScalar(0.7); + this.body.add(mesh(new THREE.SphereGeometry(0.06, 10, 8), bulb, 0, 1.1, 0, false)); + this.root.scale.setScalar(0.001); + } + + /** What it's on, on a card over its head: its kind on the chip, what it was asked to do, and its latest tool call. */ + setLabel(kind: string, title: string, doing?: string) { + const key = `${kind}|${title}|${doing ?? ''}`; + if (key === this.tagKey) return; + this.tagKey = key; + if (this.tag) { + this.root.remove(this.tag); + disposeSprite(this.tag); + } + this.tag = cardSprite({ chip: { text: kind, bg: '#ffd166', color: '#2b2d42' }, title, body: doing, bg: '#fffaf3', maxWidth: 340 }); + this.tag.position.y = 1.3; + this.root.add(this.tag); + } + + /** Pops in, then bobs and waves its arms about as it works. */ + update(dt: number, t: number, size: number) { + this.popT = Math.min(1, this.popT + dt * 3); + const pop = 1 - Math.pow(1 - this.popT, 3); + this.root.scale.setScalar(Math.max(0.001, size * pop)); + const p = t * 5 + this.phase; + this.body.position.y = Math.abs(Math.sin(p)) * 0.06; + this.body.rotation.z = Math.sin(p * 0.5) * 0.06; + this.arms[0].rotation.x = -0.6 + Math.sin(p) * 0.5; + this.arms[1].rotation.x = -0.6 - Math.sin(p) * 0.5; + this.blinkAt -= dt; + const shut = this.blinkAt < 0; + for (const e of this.eyes) e.scale.y = shut ? 0.15 : 1; + if (this.blinkAt < -0.12) this.blinkAt = 2 + Math.random() * 3; + } + + dispose() { + if (this.tag) disposeSprite(this.tag); + this.root.traverse((o) => { + if (o instanceof THREE.Mesh) o.geometry.dispose(); + }); + } +} + +/** What a subagent's chip says it is. */ +export function subagentKind(s: Pick): string { + if (s.type === 'workflow-subagent') return `๐Ÿ”€ ${s.phase ?? 'workflow'}`.toUpperCase(); + if (s.type === 'Explore') return '๐Ÿ”Ž EXPLORE'; + if (s.type === 'Plan') return '๐Ÿ“ PLAN'; + if (s.type === 'general-purpose') return '๐Ÿค– AGENT'; + return `๐Ÿค– ${s.type}`.toUpperCase(); +} + +/** + * Subagents standing together under `parent`, the i-th at `spot(i)` (x, z, and the way it faces), + * kept in step with a list: new ones pop in, finished ones go, and past what's shown a sign says how + * many more there are. + */ +export class SubagentCrowd { + private figures = new Map(); + private more: THREE.Sprite | null = null; + private moreN = 0; + + constructor( + readonly parent: THREE.Object3D, + private spot: (i: number) => readonly [x: number, z: number, rotY: number], + private size: number, + ) {} + + get count(): number { + return this.figures.size; + } + + sync(list: readonly Subagent[], more: number) { + const ids = new Set(list.map((s) => s.id)); + for (const [id, f] of this.figures) { + if (ids.has(id)) continue; + f.root.removeFromParent(); + f.dispose(); + this.figures.delete(id); + } + list.forEach((s, i) => { + let f = this.figures.get(s.id); + if (!f) { + f = new SubagentFigure(subagentColor(s.id)); + this.parent.add(f.root); + this.figures.set(s.id, f); + } + const [x, z, rotY] = this.spot(i); + f.root.position.set(x, 0, z); + f.root.rotation.y = rotY; + f.setLabel(subagentKind(s), s.title ?? s.type, s.activity); + }); + if (more !== this.moreN) { + this.moreN = more; + if (this.more) { + this.more.removeFromParent(); + disposeSprite(this.more); + this.more = null; + } + if (more > 0) { + this.more = textSprite(`+${more} more`, { bg: '#ffd166', size: 30 }); + const [x, z] = this.spot(list.length); + this.more.position.set(x, 0.6, z); + this.parent.add(this.more); + } + } + } + + update(dt: number, t: number) { + for (const f of this.figures.values()) f.update(dt, t, this.size); + } + + dispose() { + this.sync([], 0); + } +} diff --git a/src/server/config.ts b/src/server/config.ts index 177613751..d8f4ac65e 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -61,6 +61,8 @@ export interface Config { weather?: Weather; /** The sky keeps real time (a day a day), instead of a whole day and night every hour. */ realTimeSky: boolean; + /** Claude Code sessions the office didn't start show up as visitors (see server/visitors/). */ + visitors: boolean; } export interface RTCIceServerLike { @@ -161,6 +163,10 @@ Options: it's night there, instead of a day and night every hour (โš™๏ธ Settings can switch it) (env AGENT_OFFICE_SKY_CLOCK=real) + --no-visitors Don't show the Claude Code sessions the office didn't + start (the desktop app, a terminal) as visitors; it + only ever reads their transcripts under ~/.claude + (env AGENT_OFFICE_VISITORS=0) -h, --help Show this help Started in a terminal, the office opens in your browser already signed in, with @@ -241,6 +247,7 @@ export function loadConfig(argv: string[]): Config { let city = process.env.AGENT_OFFICE_CITY || ''; let weather = process.env.AGENT_OFFICE_WEATHER || ''; let realTimeSky = process.env.AGENT_OFFICE_SKY_CLOCK === 'real'; + let visitors = process.env.AGENT_OFFICE_VISITORS !== '0'; const iceServers: RTCIceServerLike[] = [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }]; // A container can't take --turn (deploy/container/compose.yaml), so the TURN servers come from the environment too. for (const url of (process.env.AGENT_OFFICE_TURN ?? '').split(/\s+/).filter(Boolean)) iceServers.push(parseTurn(url)); @@ -326,6 +333,9 @@ export function loadConfig(argv: string[]): Config { case '--real-time-sky': realTimeSky = true; break; + case '--no-visitors': + visitors = false; + break; default: if (a.startsWith('-')) { console.error(`agent-office: unknown option ${a}\n`); @@ -463,6 +473,7 @@ export function loadConfig(argv: string[]): Config { city: city.trim() || undefined, weather: (weather as Weather) || undefined, realTimeSky, + visitors, }; } diff --git a/src/server/office/context.ts b/src/server/office/context.ts index 96090a2e0..644b54753 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -25,6 +25,7 @@ import type { OfficePrompts } from '../prompts.js'; import type { LeaveOnMerge } from '../leave-on-merge.js'; import type { ChatLog } from '../history.js'; import type { Arcade, HighScores } from '../cabinet.js'; +import type { Visitors } from '../visitors/index.js'; import type { AgentProvider, FloorInfo, Me, ServerMsg, ServiceInfo, ServicesState, SignInKind } from '../../shared/protocol.js'; import type { Client } from './client.js'; import type { Spot } from './input.js'; @@ -88,6 +89,8 @@ export interface LateServices { services: Services; images: ImageProxy; upgrader: Upgrader; + /** Claude Code sessions the office didn't start, shown on the floors as visitors (see visitors/). */ + visitors: Visitors; /** A floor's Services board: its own workers' servers. */ servicesState(floor: Floor | undefined, items?: ServiceInfo[]): ServicesState; } diff --git a/src/server/office/services.ts b/src/server/office/services.ts index 847f05a52..f6c58bbc5 100644 --- a/src/server/office/services.ts +++ b/src/server/office/services.ts @@ -17,6 +17,7 @@ import { Themes } from '../theme.js'; import { Maps } from '../maps.js'; import { OfficePrompts } from '../prompts.js'; import { LeaveOnMerge } from '../leave-on-merge.js'; +import { claudeProjectsDir, Visitors } from '../visitors/index.js'; import type { ServiceInfo, ServicesState } from '../../shared/protocol.js'; import type { BuildingServices, Ctx, LateServices } from './context.js'; import type { Client } from './client.js'; @@ -161,5 +162,20 @@ export function createLateServices(ctx: Ctx): LateServices { process.kill(process.pid, 'SIGTERM'); }, ); - return { team, tailnet, services, images, upgrader, servicesState }; + // Claude Code sessions the office didn't start (the desktop app, a terminal), on the floor of the + // repository they work in: read off their transcripts, never touched (see visitors/). + const visitors = new Visitors( + { root: claudeProjectsDir(), off: !cfg.visitors }, + { + floors: () => [...floors.values()].map((f) => ({ id: f.id, dir: f.dir, repo: f.def.repo })), + workerSessions: () => new Set([...floors.values()].flatMap((f) => f.workers.list().map((w) => w.sessionId).filter((id): id is string => !!id))), + watched: () => clients.size > 0, + send: (id, state) => { + const floor = floors.get(id); + if (floor) ctx.toFloor(floor, { t: 'visitors', state }); + }, + }, + ); + visitors.start(); + return { team, tailnet, services, images, upgrader, visitors, servicesState }; } diff --git a/src/server/providers/claude.ts b/src/server/providers/claude.ts index 1eac357c3..783c7f2be 100644 --- a/src/server/providers/claude.ts +++ b/src/server/providers/claude.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { FAILS_TO_DESPAIR, outputFailed, toolAction } from '../../shared/actions.js'; import { MCP_READ_ONLY, writeClaudeMcpConfig } from '../office-workers.js'; import { QUEUE_AGENT_DISALLOWED_TOOLS } from '../stations.js'; +import { noteSubagent } from '../subagents.js'; import { answered, notified, wantsPermission } from '../workers/lifecycle.js'; import { shq } from '../workers/process.js'; import type { WorkerHandle } from '../workers/types.js'; @@ -40,6 +41,9 @@ function writeHookSettings(dataDir: string): string { ['PreToolUse', undefined], ['PostToolUse', undefined], ['PostToolUseFailure', undefined], + // Its subagents, each shown beside its desk while it runs (see subagents.ts). + ['SubagentStart', undefined], + ['SubagentStop', undefined], ]; // Minimal VPS images sometimes lack curl; the office's own node binary is always there. const nodeHook = path.join(dataDir, 'hook.cjs'); @@ -129,6 +133,13 @@ function claudeHook(h: WorkerHandle, event: string, payload: any): boolean { h.persist(); } h.scheduleScan(); + // Its subagents: a new session has none, and the rest only start and stop them, or say what one is doing. + const subagents = noteSubagent(info, event, payload, { transcript: h.tracker.transcript, activity: event === 'PreToolUse' ? describeTool(payload) : undefined, now }); + if (event === 'SubagentStart' || event === 'SubagentStop') { + if (subagents) h.emit(); + return true; + } + if (subagents && event === 'SessionStart') h.emit(); switch (event) { case 'SessionStart': if (payload?.source === 'clear') { diff --git a/src/server/subagents.ts b/src/server/subagents.ts new file mode 100644 index 000000000..c328eae1a --- /dev/null +++ b/src/server/subagents.ts @@ -0,0 +1,126 @@ +// A Claude Code worker's subagents (the Agent tool, a workflow's agents), from its hooks: each one +// stands beside its worker's desk from SubagentStart to SubagentStop (see features/subagents in the +// page). A subagent's own tool calls reach the worker's hooks too, with its agent_id, and keep it up +// to date; one the office only hears of that way (it started before the office did) is taken in then. +// What it was asked to do is read off the meta file Claude Code writes next to its transcript. +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import type { Subagent, WorkerInfo } from '../shared/protocol.js'; +import { SUBAGENT_STALE_MS } from '../shared/protocol.js'; +import { truncate } from './workers/util.js'; + +/** A worker keeps at most this many (a big workflow runs dozens at once). */ +export const MAX_SUBAGENTS = 24; + +/** Claude Code's agent ids are short hex; anything else is never used in a path. */ +const AGENT_ID = /^[A-Za-z0-9_-]{1,64}$/; + +/** What Claude Code's `agent-.meta.json` says about a subagent. */ +export interface SubagentMeta { + agentType?: string; + description?: string; + workflowPhase?: string; + finished?: boolean; +} + +function readMeta(file: string): SubagentMeta | undefined { + try { + const m = JSON.parse(readFileSync(file, 'utf8')); + return m && typeof m === 'object' ? m : undefined; + } catch { + return undefined; + } +} + +/** + * The meta file of subagent `id` of the session whose transcript is `transcript`: next to its own + * transcript when the hook named it (SubagentStop does), else in the session's `subagents/` folder, + * or in one of its workflows' folders under it. + */ +export function subagentMeta(transcript: string | undefined, id: string, agentTranscript?: string): SubagentMeta | undefined { + if (!AGENT_ID.test(id)) return undefined; + if (agentTranscript?.endsWith(`agent-${id}.jsonl`)) { + const m = readMeta(agentTranscript.replace(/\.jsonl$/, '.meta.json')); + if (m) return m; + } + if (!transcript?.endsWith('.jsonl')) return undefined; + const dir = path.join(transcript.slice(0, -'.jsonl'.length), 'subagents'); + const own = readMeta(path.join(dir, `agent-${id}.meta.json`)); + if (own) return own; + let runs: string[] = []; + try { + runs = readdirSync(path.join(dir, 'workflows')); + } catch { + return undefined; + } + for (const run of runs) { + const m = readMeta(path.join(dir, 'workflows', run, `agent-${id}.meta.json`)); + if (m) return m; + } + return undefined; +} + +function text(v: unknown, max = 80): string | undefined { + return typeof v === 'string' && v.trim() ? truncate(v.trim(), max) : undefined; +} + +export interface SubagentNote { + /** The worker's own session transcript, where its subagents' files are. */ + transcript?: string; + /** What a PreToolUse from the subagent says it's doing ("Bash: npm test"). */ + activity?: string; + now?: number; +} + +/** + * Takes one of a worker's Claude Code hook events into its subagents (WorkerInfo.subagents): a new + * session has none yet, SubagentStart adds one, SubagentStop takes it off, and any event from inside + * one (it carries an agent_id) says it's still at it. Ones not heard from in SUBAGENT_STALE_MS went + * with a turn that was stopped. Says whether the list changed in a way worth showing. + */ +export function noteSubagent(info: WorkerInfo, event: string, payload: any, note: SubagentNote = {}): boolean { + const now = note.now ?? Date.now(); + const before = info.subagents ?? []; + let list = before.filter((s) => now - s.seenAt < SUBAGENT_STALE_MS); + let changed = list.length !== before.length; + const done = (): boolean => { + info.subagents = list.length ? list : undefined; + return changed; + }; + if (event === 'SessionStart') { + changed ||= list.length > 0; + list = []; + return done(); + } + const id = typeof payload?.agent_id === 'string' && AGENT_ID.test(payload.agent_id) ? payload.agent_id : undefined; + if (!id) return done(); + if (event === 'SubagentStop') { + const kept = list.filter((s) => s.id !== id); + changed ||= kept.length !== list.length; + list = kept; + return done(); + } + let s = list.find((x) => x.id === id); + if (!s) { + if (list.length >= MAX_SUBAGENTS) return done(); + s = { id, type: text(payload?.agent_type, 40) ?? 'agent', since: now, seenAt: now }; + list = [...list, s]; + changed = true; + } + s.seenAt = now; + if (!s.title) { + const meta = subagentMeta(note.transcript, id, typeof payload?.agent_transcript_path === 'string' ? payload.agent_transcript_path : undefined); + const title = text(meta?.description); + if (title) { + s.title = title; + s.phase = text(meta?.workflowPhase, 40); + s.type = text(meta?.agentType, 40) ?? s.type; + changed = true; + } + } + if (event === 'PreToolUse' && note.activity) { + s.activity = note.activity; + changed = true; + } + return done(); +} diff --git a/src/server/visitors/index.ts b/src/server/visitors/index.ts new file mode 100644 index 000000000..9ebf2428d --- /dev/null +++ b/src/server/visitors/index.ts @@ -0,0 +1,188 @@ +// Visitors: Claude Code sessions the office didn't start (the Claude desktop app, a terminal, an +// IDE) show up as characters on the floor whose repository they work in, or in the lobby (the +// building's first floor) when no floor's is theirs, with their subagents round them. Read-only: +// the office looks at the transcripts Claude Code writes (see scan.ts) and never writes to them, +// never types into those sessions and never signals them. +import { execFile } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { normalizeRepo, sameRepo } from '../../shared/floors.js'; +import { VISITOR_SUBAGENTS, type Visitor, type VisitorsState } from '../../shared/protocol.js'; +import { SessionScanner, type Sighting } from './scan.js'; + +/** How often the projects folder is looked over, while anyone is in the office. */ +const EVERY_MS = 4000; +/** A floor shows at most this many visitors. */ +export const MAX_VISITORS = 8; +/** A folder's git origin is asked again after this long. */ +const ORIGIN_TTL = 10 * 60_000; + +/** Claude Code's projects folder: under CLAUDE_CONFIG_DIR when that's set, else ~/.claude. */ +export function claudeProjectsDir(env: NodeJS.ProcessEnv = process.env): string { + return path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects'); +} + +/** One floor, as the visitors need it. */ +export interface VisitorFloor { + id: string; + /** Its checkout. */ + dir: string; + /** owner/name on GitHub, when known. */ + repo?: string; +} + +export interface VisitorsDeps { + /** The building's floors, the lobby (its first) first. */ + floors(): VisitorFloor[]; + /** The session ids of the office's own workers, on every floor: those are never visitors. */ + workerSessions(): Set; + /** Whether anyone is in the office to see them: nobody looks while nobody's there. */ + watched(): boolean; + /** A floor's visitors changed. */ + send(floor: string, state: VisitorsState): void; +} + +/** Whether `child` is `dir` or inside it. */ +function inside(child: string, dir: string): boolean { + const rel = path.relative(dir, child); + return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel)); +} + +/** The folder a session runs in, as its last two parts. */ +export function shortFolder(cwd: string | undefined): string { + if (!cwd) return '?'; + return cwd.split(/[\\/]+/).filter(Boolean).slice(-2).join('/') || cwd; +} + +/** + * The floor a session in `cwd` visits: the one whose checkout it's in, else the one whose repository + * its folder's origin is, else the lobby. Undefined for a worker's own folder under a floor's + * .agent-office (a worktree, a workspace) and when there are no floors. + */ +export function floorFor(cwd: string | undefined, origin: string | undefined, floors: VisitorFloor[]): { floor: string; lobby: boolean } | undefined { + if (!floors.length) return undefined; + if (cwd) { + if (floors.some((f) => inside(cwd, path.join(f.dir, '.agent-office')))) return undefined; + const own = floors.find((f) => inside(cwd, f.dir)); + if (own) return { floor: own.id, lobby: false }; + } + const byRepo = origin ? floors.find((f) => sameRepo(f.repo, origin)) : undefined; + if (byRepo) return { floor: byRepo.id, lobby: false }; + return { floor: floors[0].id, lobby: true }; +} + +/** What a sighting looks like on the floor. */ +export function visitorOf(s: Sighting, lobby: boolean): Visitor { + const { facts, turn, subagents } = s; + // Its own turn is over, but it has subagents at work (a workflow it started): it waits on them. + const status = turn.status === 'done' && subagents.length ? 'working' : turn.status; + const activity = turn.status === 'done' && subagents.length ? `${subagents.length} subagent${subagents.length === 1 ? '' : 's'} at work` : turn.activity; + return { + id: s.id, + title: facts.title ?? facts.prompt ?? shortFolder(facts.cwd), + app: facts.app, + folder: shortFolder(facts.cwd), + ...(facts.branch ? { branch: facts.branch } : {}), + status, + ...(activity ? { activity } : {}), + ...(turn.model ? { model: turn.model } : {}), + seenAt: Math.round(s.seenAt), + lobby, + subagents: subagents.slice(0, VISITOR_SUBAGENTS), + subagentCount: subagents.length, + }; +} + +/** The repository a folder is a checkout of, by its git origin: asked once in a while, without waiting on it. */ +class Origins { + private known = new Map }>(); + + /** What's known now; asks git again in the background when that's stale. */ + async of(cwd: string, now: number): Promise { + const had = this.known.get(cwd); + if (had && (had.asking || now - had.at < ORIGIN_TTL)) { + if (had.asking) await had.asking; + return this.known.get(cwd)?.repo; + } + const entry: { repo?: string; at: number; asking?: Promise } = { repo: had?.repo, at: now }; + entry.asking = new Promise((resolve) => { + execFile('git', ['remote', 'get-url', 'origin'], { cwd, timeout: 5000, encoding: 'utf8' }, (err, out) => { + entry.repo = err ? undefined : normalizeRepo(out.trim()); + entry.asking = undefined; + resolve(); + }); + }); + this.known.set(cwd, entry); + await entry.asking; + return entry.repo; + } +} + +export class Visitors { + readonly scanner: SessionScanner; + private timer?: NodeJS.Timeout; + private origins = new Origins(); + private byFloor = new Map(); + private sent = new Map(); + private busy = false; + /** Every session the office's workers have had: one a worker left behind (a /clear) is no visitor either. */ + private workers = new Set(); + + constructor( + private opts: { root: string; off?: boolean }, + private deps: VisitorsDeps, + ) { + this.scanner = new SessionScanner(opts.root); + } + + start() { + if (this.opts.off || this.timer) return; + this.timer = setInterval(() => { + if (this.deps.watched()) void this.look(); + }, EVERY_MS); + this.timer.unref(); + void this.look(); + } + + stop() { + clearInterval(this.timer); + this.timer = undefined; + } + + /** The visitors on `floor`, for someone arriving there. */ + state(floor: string | undefined): VisitorsState { + if (this.opts.off) return { visitors: [], off: true }; + return { visitors: (floor && this.byFloor.get(floor)) || [] }; + } + + /** Looks over the projects folder once, and tells each floor whose visitors changed. */ + async look(now = Date.now()): Promise { + if (this.busy || this.opts.off) return; + this.busy = true; + try { + for (const id of this.deps.workerSessions()) this.workers.add(id); + const floors = this.deps.floors(); + const sightings = this.scanner.scan((id) => this.workers.has(id), now); + const next = new Map(); + for (const s of sightings) { + const cwd = s.facts.cwd; + const origin = cwd && !floors.some((f) => inside(cwd, f.dir)) ? await this.origins.of(cwd, now) : undefined; + const where = floorFor(cwd, origin, floors); + if (!where) continue; + const list = next.get(where.floor) ?? []; + list.push(visitorOf(s, where.lobby)); + next.set(where.floor, list); + } + this.byFloor = new Map([...next].map(([floor, list]) => [floor, list.sort((a, b) => this.scanner.since(a.id) - this.scanner.since(b.id) || a.id.localeCompare(b.id)).slice(0, MAX_VISITORS)])); + for (const f of floors) { + const state = this.state(f.id); + const key = JSON.stringify(state); + if (this.sent.get(f.id) === key) continue; + this.sent.set(f.id, key); + this.deps.send(f.id, state); + } + } finally { + this.busy = false; + } + } +} diff --git a/src/server/visitors/scan.ts b/src/server/visitors/scan.ts new file mode 100644 index 000000000..250a16969 --- /dev/null +++ b/src/server/visitors/scan.ts @@ -0,0 +1,212 @@ +// Finding the Claude Code sessions in use right now under its projects folder +// (//.jsonl), with the subagents each has running +// (/subagents/agent-.jsonl, a workflow's under /subagents/workflows//). +// It only lists, stats and reads: nothing under that folder is ever written, moved or locked. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import type { Subagent } from '../../shared/protocol.js'; +import { entries, readSlice, sessionOf, turnOf, type SessionFacts, type TurnFacts } from './transcript.js'; +import { truncate } from '../workers/util.js'; + +export interface ScanTimes { + /** A session whose transcript was written this recently is about (unless it has subagents at work). */ + window: number; + /** A subagent whose transcript was written this recently is still at work. */ + agentWindow: number; + /** A session older than this can't have subagents at work: its folder isn't looked in. */ + long: number; + /** Transcripts older than `long` are only stat'ed again this often. */ + restat: number; +} + +export const SCAN_TIMES: ScanTimes = { window: 10 * 60_000, agentWindow: 3 * 60_000, long: 6 * 3600_000, restat: 60_000 }; + +/** How much of a transcript is read: its head for its first prompt and where it runs, its tail for how it's doing. */ +const HEAD = 64 * 1024; +const TAIL = 256 * 1024; +/** A tail that's all one huge line (a big tool result) is read again this big. */ +const BIG_TAIL = 2 * 1024 * 1024; +const AGENT_TAIL = 32 * 1024; +/** Session ids are UUIDs; agent ids short hex. Nothing else is taken for one. */ +const SESSION_FILE = /^([0-9a-f-]{8,64})\.jsonl$/i; +const AGENT_FILE = /^agent-([A-Za-z0-9_-]{1,64})\.jsonl$/; + +/** A session in use, as its files say. */ +export interface Sighting { + id: string; + file: string; + /** When its transcript, or one of its subagents', was last written. */ + seenAt: number; + facts: SessionFacts; + turn: TurnFacts; + /** Its subagents at work, most recently heard from first. */ + subagents: Subagent[]; +} + +interface Known { + size: number; + mtime: number; + facts: SessionFacts; + turn: TurnFacts; +} + +interface KnownAgent { + size: number; + mtime: number; + sub: Subagent; +} + +function list(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +function stat(file: string): { size: number; mtime: number; dir: boolean } | undefined { + try { + const s = statSync(file); + return { size: s.size, mtime: s.mtimeMs, dir: s.isDirectory() }; + } catch { + return undefined; + } +} + +function json(file: string): any { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return undefined; + } +} + +/** Looks over Claude Code's projects folder for the sessions in use; keeps what it read of each until the file changes. */ +export class SessionScanner { + private known = new Map(); + private agents = new Map(); + /** Transcripts too old to matter, and when they were last stat'ed. */ + private quiet = new Map(); + /** When each session was first seen in use, which keeps the visitors in the order they came. */ + private firstSeen = new Map(); + /** The subagents' transcripts looked at in this scan: what's kept of the others goes. */ + private looked = new Set(); + + constructor( + readonly root: string, + private times: ScanTimes = SCAN_TIMES, + ) {} + + /** When a session was first seen in use. */ + since(id: string): number { + return this.firstSeen.get(id) ?? 0; + } + + /** The sessions in use, but for those `skip` says are the office's own. */ + scan(skip: (id: string) => boolean, now = Date.now()): Sighting[] { + const out: Sighting[] = []; + const seen = new Set(); + this.looked.clear(); + for (const project of list(this.root)) { + const dir = path.join(this.root, project); + for (const name of list(dir)) { + const m = SESSION_FILE.exec(name); + if (!m) continue; + const id = m[1]; + const file = path.join(dir, name); + const quietAt = this.quiet.get(file); + if (quietAt !== undefined && now - quietAt < this.times.restat) continue; + const st = stat(file); + if (!st || st.dir) continue; + if (now - st.mtime > this.times.long) { + this.quiet.set(file, now); + this.known.delete(file); + continue; + } + this.quiet.delete(file); + if (skip(id)) continue; + const subagents = this.subagentsOf(file, now); + const fresh = now - st.mtime < this.times.window; + if (!fresh && !subagents.length) continue; + const read = this.read(file, st); + if (!read.turn.found && !subagents.length) continue; + seen.add(id); + if (!this.firstSeen.has(id)) this.firstSeen.set(id, now); + const seenAt = Math.max(st.mtime, ...subagents.map((s) => s.seenAt)); + out.push({ id, file, seenAt, facts: read.facts, turn: read.turn, subagents }); + } + } + for (const id of this.firstSeen.keys()) if (!seen.has(id)) this.firstSeen.delete(id); + for (const file of this.agents.keys()) if (!this.looked.has(file)) this.agents.delete(file); + return out; + } + + /** What a transcript says, read again only when it has changed. */ + private read(file: string, st: { size: number; mtime: number }): Known { + const had = this.known.get(file); + if (had && had.size === st.size && had.mtime === st.mtime) return had; + const headSlice = readSlice(file, 'head', HEAD); + const head = entries(headSlice, 'head'); + let tail = headSlice.whole ? head : entries(readSlice(file, 'tail', TAIL), 'tail'); + let turn = turnOf(tail); + if (!turn.found && !headSlice.whole) { + tail = entries(readSlice(file, 'tail', BIG_TAIL), 'tail'); + turn = turnOf(tail); + } + let facts = sessionOf(head, tail); + if (!facts.title) { + // The desktop app keeps its title beside the transcript too. + const named = json(path.join(file.slice(0, -'.jsonl'.length), 'custom-title.json'))?.customTitle; + if (typeof named === 'string' && named.trim()) facts = { ...facts, title: truncate(named.trim(), 70) }; + } + const known = { size: st.size, mtime: st.mtime, facts, turn }; + this.known.set(file, known); + return known; + } + + /** The subagents of the session whose transcript is `file` that are at work: written to lately, and not marked finished. */ + private subagentsOf(file: string, now: number): Sighting['subagents'] { + const base = path.join(file.slice(0, -'.jsonl'.length), 'subagents'); + const dirs = [base, ...list(path.join(base, 'workflows')).map((run) => path.join(base, 'workflows', run))]; + const out: Subagent[] = []; + for (const dir of dirs) { + for (const name of list(dir)) { + const m = AGENT_FILE.exec(name); + if (!m) continue; + const agentFile = path.join(dir, name); + const st = stat(agentFile); + if (!st || st.dir || now - st.mtime > this.times.agentWindow) { + this.agents.delete(agentFile); + continue; + } + this.looked.add(agentFile); + const sub = this.agent(agentFile, m[1], st); + if (sub) out.push(sub); + } + } + return out.sort((a, b) => b.seenAt - a.seenAt); + } + + private agent(file: string, id: string, st: { size: number; mtime: number }): Subagent | undefined { + const had = this.agents.get(file); + if (had && had.size === st.size && had.mtime === st.mtime) return had.sub; + const meta = json(file.replace(/\.jsonl$/, '.meta.json')) ?? {}; + if (meta.finished === true) { + this.agents.delete(file); + return undefined; + } + const turn = turnOf(entries(readSlice(file, 'tail', AGENT_TAIL), 'tail'), true); + const text = (v: unknown, max: number) => (typeof v === 'string' && v.trim() ? truncate(v.trim(), max) : undefined); + const sub: Subagent = { + id, + type: text(meta.agentType, 40) ?? 'agent', + title: text(meta.description, 80), + phase: text(meta.workflowPhase, 40), + activity: turn.activity, + since: had?.sub.since ?? Math.round(st.mtime), + seenAt: Math.round(st.mtime), + }; + this.agents.set(file, { size: st.size, mtime: st.mtime, sub }); + return sub; + } +} diff --git a/src/server/visitors/transcript.ts b/src/server/visitors/transcript.ts new file mode 100644 index 000000000..6a5ea9aa8 --- /dev/null +++ b/src/server/visitors/transcript.ts @@ -0,0 +1,165 @@ +// Reading a Claude Code session's transcript for the visitors: its first and last few hundred +// kilobytes (they run to tens of megabytes), opened read-only and never written to. +import { closeSync, fstatSync, openSync, readSync } from 'node:fs'; +import type { VisitorApp, VisitorStatus } from '../../shared/protocol.js'; +import { truncate } from '../workers/util.js'; + +/** A slice of `file`: its first or last `bytes`, and whether that's all of it. '' when it can't be read. */ +export function readSlice(file: string, from: 'head' | 'tail', bytes: number): { text: string; whole: boolean } { + let fd: number | undefined; + try { + fd = openSync(file, 'r'); + const size = fstatSync(fd).size; + const len = Math.min(bytes, size); + const buf = Buffer.alloc(len); + readSync(fd, buf, 0, len, from === 'head' ? 0 : size - len); + return { text: buf.toString('utf8'), whole: len === size }; + } catch { + return { text: '', whole: true }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +/** The JSON entries in a slice, oldest first: whole lines only (a slice can start or end mid-line). */ +export function entries(slice: { text: string; whole: boolean }, from: 'head' | 'tail'): any[] { + const lines = slice.text.split('\n'); + if (!slice.whole) { + if (from === 'tail') lines.shift(); + else lines.pop(); + } + const out: any[] = []; + for (const line of lines) { + if (!line.trim()) continue; + try { + const e = JSON.parse(line); + if (e && typeof e === 'object') out.push(e); + } catch { + // a line cut short, or not JSON: skipped + } + } + return out; +} + +/** What the app that wrote it calls itself (`entrypoint`), as the visitor's sign says it. */ +export function appOf(entrypoint: unknown): VisitorApp { + if (typeof entrypoint !== 'string') return 'other'; + if (entrypoint === 'claude-desktop') return 'desktop'; + if (entrypoint === 'cli') return 'cli'; + if (/vscode|jetbrains|ide|cursor|zed/i.test(entrypoint)) return 'ide'; + return 'other'; +} + +/** One line of what a tool call is about: "Bash: npm test". */ +export function describeToolUse(block: any): string { + // An MCP server's tool (mcp____) goes by the tool's own name. + const name = typeof block?.name === 'string' ? block.name.replace(/^mcp__.+?__/, '') : 'tool'; + const input = block?.input ?? {}; + const detail = input.description ?? input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.query ?? input.prompt ?? ''; + return truncate(typeof detail === 'string' && detail ? `${name}: ${detail.replace(/\s+/g, ' ')}` : name, 80); +} + +/** The text of a message's content: a string, or its text blocks. */ +function textOf(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter((b) => b?.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join(' '); +} + +/** Tools that wait on the person at the keyboard, not on the model. */ +const ASKS = new Set(['AskUserQuestion', 'ExitPlanMode']); + +/** What the last turn of a session says about it, from the tail of its transcript (oldest entry first). */ +export interface TurnFacts { + status: VisitorStatus; + activity?: string; + model?: string; + /** Whether a message was found at all: a tail of one huge line has none, and is read again bigger. */ + found: boolean; +} + +/** + * How a session is getting on, from its latest messages. `sidechain`: it's a subagent's transcript, + * every entry of which is marked as off the main thread. + */ +export function turnOf(tail: any[], sidechain = false): TurnFacts { + let model: string | undefined; + /** A tool's result came last: it's at work, on whatever the call before it was. */ + let resulted = false; + for (let i = tail.length - 1; i >= 0; i--) { + const e = tail[i]; + if ((e.isSidechain && !sidechain) || e.isMeta) continue; + const m = e.message; + if (e.type === 'assistant' && m && typeof m === 'object') { + if (!model && typeof m.model === 'string' && !m.model.startsWith('<')) model = m.model; + const content = Array.isArray(m.content) ? m.content : []; + const tools = content.filter((b: any) => b?.type === 'tool_use'); + const last = tools[tools.length - 1]; + if (resulted) return { status: 'working', activity: last ? describeToolUse(last) : undefined, model, found: true }; + if (m.stop_reason === 'tool_use' || (last && !m.stop_reason)) { + return { status: last && ASKS.has(last.name) ? 'needs_input' : 'working', activity: last ? describeToolUse(last) : undefined, model, found: true }; + } + if (!m.stop_reason) return { status: 'working', model, found: true }; + const said = textOf(m.content).replace(/\s+/g, ' ').trim(); + return { status: 'done', activity: said ? truncate(said, 80) : undefined, model, found: true }; + } + if (e.type === 'user' && m && typeof m === 'object' && !resulted) { + const content = m.content; + if (Array.isArray(content) && content.some((b: any) => b?.type === 'tool_result')) { + resulted = true; + continue; + } + // Esc: the turn was stopped, and it waits for the next prompt. + if (/^\[Request interrupted/.test(textOf(content).trim())) return { status: 'done', model, found: true }; + return { status: 'working', model, found: true }; + } + } + return { status: resulted ? 'working' : 'done', model, found: resulted }; +} + +/** What the head and tail of a transcript say about the session as a whole. */ +export interface SessionFacts { + /** Its name in the app (/rename, the desktop app's title), the latest given. */ + title?: string; + /** Its first prompt, for one without a name. */ + prompt?: string; + cwd?: string; + branch?: string; + app: VisitorApp; +} + +/** A prompt worth calling the session by: a person's, not a command's or a reminder's. */ +function promptOf(e: any): string | undefined { + if (e.type !== 'user' || e.isMeta || e.isSidechain || e.origin?.kind === 'task-notification') return undefined; + const text = textOf(e.message?.content).replace(/\s+/g, ' ').trim(); + if (!text || text.startsWith('<') || text.startsWith('[Request interrupted')) return undefined; + return truncate(text, 70); +} + +export function sessionOf(head: any[], tail: any[]): SessionFacts { + const facts: SessionFacts = { app: 'other' }; + const named = (e: any) => (e.type === 'custom-title' ? e.customTitle : e.type === 'agent-name' ? e.agentName : e.type === 'summary' ? e.summary : undefined); + for (const list of [tail, head]) { + for (let i = list.length - 1; i >= 0 && !facts.title; i--) { + const t = named(list[i]); + if (typeof t === 'string' && t.trim()) facts.title = truncate(t.trim(), 70); + } + } + for (const e of head) { + facts.prompt ??= promptOf(e); + if (typeof e.entrypoint === 'string' && facts.app === 'other') facts.app = appOf(e.entrypoint); + if (typeof e.cwd === 'string') facts.cwd ??= e.cwd; + } + for (let i = tail.length - 1; i >= 0; i--) { + const e = tail[i]; + if (typeof e.cwd === 'string' && !facts.cwd) facts.cwd = e.cwd; + // A detached checkout says HEAD, which names no branch. + if (typeof e.gitBranch === 'string' && e.gitBranch && e.gitBranch !== 'HEAD' && !facts.branch) facts.branch = e.gitBranch; + if (facts.app === 'other' && typeof e.entrypoint === 'string') facts.app = appOf(e.entrypoint); + if (facts.cwd && facts.branch && facts.app !== 'other') break; + } + return facts; +} diff --git a/src/server/ws/handlers/index.ts b/src/server/ws/handlers/index.ts index dd119c2ea..029a0fe4d 100644 --- a/src/server/ws/handlers/index.ts +++ b/src/server/ws/handlers/index.ts @@ -20,6 +20,7 @@ import { servicesView, settingsHandlers } from './settings.js'; import { signinsHandlers } from './signins.js'; import { teamHandlers } from './team.js'; import { usageHandlers } from './usage.js'; +import { visitorsView } from './visitors.js'; import { whiteboardHandlers, whiteboardHooks, whiteboardView } from './whiteboard.js'; import { jailView, workerHandlers, workerHooks, workersView } from './workers.js'; import type { FeatureHooks, HandlerMap, ViewPieces } from './types.js'; @@ -73,4 +74,5 @@ export const views: ViewPieces = { whiteboard: whiteboardView, meeting: meetingView, cabinet: cabinetView, + visitors: visitorsView, }; diff --git a/src/server/ws/handlers/visitors.ts b/src/server/ws/handlers/visitors.ts new file mode 100644 index 000000000..44a2b9c68 --- /dev/null +++ b/src/server/ws/handlers/visitors.ts @@ -0,0 +1,6 @@ +// Visitors take no messages: they're only looked at. What someone arriving on a floor is sent of them. +import type { VisitorsState } from '../../../shared/protocol.js'; +import type { Ctx } from '../../office/context.js'; +import type { Floor } from '../../floor.js'; + +export const visitorsView = (ctx: Ctx, floor: Floor | undefined): VisitorsState => ctx.visitors.state(floor?.id); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ee0d258ef..509fc5ffd 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -15,6 +15,7 @@ import type { RooftopClientMsg, RooftopServerMsg } from './protocol/rooftop.js'; import type { SettingsClientMsg, SettingsServerMsg } from './protocol/settings.js'; import type { BallClientMsg, CabinetClientMsg, CarClientMsg, DecorClientMsg, DogClientMsg, JukeboxClientMsg, ToysServerMsg, WhiteboardClientMsg } from './protocol/toys.js'; import type { UsageClientMsg, UsageServerMsg } from './protocol/usage.js'; +import type { VisitorsServerMsg } from './protocol/visitors.js'; import type { WorkerClientMsg, WorkerServerMsg } from './protocol/workers.js'; export * from './protocol/accounts.js'; @@ -27,8 +28,10 @@ export * from './protocol/presence.js'; export * from './protocol/queue.js'; export * from './protocol/rooftop.js'; export * from './protocol/settings.js'; +export * from './protocol/subagents.js'; export * from './protocol/toys.js'; export * from './protocol/usage.js'; +export * from './protocol/visitors.js'; export * from './protocol/workers.js'; export type ClientMsg = @@ -66,4 +69,5 @@ export type ServerMsg = | AccountsServerMsg | SettingsServerMsg | UsageServerMsg - | ToysServerMsg; + | ToysServerMsg + | VisitorsServerMsg; diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index ce908eaa2..235fbe787 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -14,6 +14,7 @@ import type { MeetingState } from './meetings.js'; import type { PeerInfo } from './presence.js'; import type { QueueState } from './queue.js'; import type { ServicesState } from './settings.js'; +import type { VisitorsState } from './visitors.js'; import type { JailState, WorkerInfo } from './workers.js'; export interface ProjectInfo { @@ -124,6 +125,8 @@ export interface FloorView { cars: CarState[]; /** Workers sent home and locked up in the dungeon, on a map that has one. */ jail: JailState; + /** Claude Code sessions the office didn't start, visiting this floor (see protocol/visitors.ts). */ + visitors: VisitorsState; } export type FloorClientMsg = diff --git a/src/shared/protocol/subagents.ts b/src/shared/protocol/subagents.ts new file mode 100644 index 000000000..0a2c4cc31 --- /dev/null +++ b/src/shared/protocol/subagents.ts @@ -0,0 +1,27 @@ +// Subagents: the agents a Claude Code session hands part of its work to (the Agent tool, a +// workflow's agents). A worker's come from its hooks (server/subagents.ts); a visitor's are read off +// the files Claude Code keeps for them (server/visitors/). + +export interface Subagent { + /** Claude Code's agent id: its transcript is `subagents/agent-.jsonl` next to the session's. */ + id: string; + /** Its agent type: "general-purpose", "Explore", a workflow's "workflow-subagent"... */ + type: string; + /** What it was asked to do (its description), once known. */ + title?: string; + /** The workflow phase it's in, for a workflow's agent. */ + phase?: string; + /** Its latest tool call, as "Bash: npm test". */ + activity?: string; + /** When it started, and when it was last heard from (ms). */ + since: number; + seenAt: number; +} + +/** A subagent nobody has heard from in this long has ended without saying so (its session was stopped mid-turn). */ +export const SUBAGENT_STALE_MS = 15 * 60_000; + +/** The subagents still running, most recently heard from first. */ +export function liveSubagents(list: readonly Subagent[] | undefined, now: number): Subagent[] { + return (list ?? []).filter((s) => now - s.seenAt < SUBAGENT_STALE_MS).sort((a, b) => b.seenAt - a.seenAt); +} diff --git a/src/shared/protocol/visitors.ts b/src/shared/protocol/visitors.ts new file mode 100644 index 000000000..100a68d09 --- /dev/null +++ b/src/shared/protocol/visitors.ts @@ -0,0 +1,46 @@ +// Visitors: Claude Code sessions the office didn't start (the Claude desktop app, a terminal, an +// IDE), seen through the transcripts Claude Code writes under ~/.claude/projects (see +// server/visitors/). The office only ever reads those; a visitor can be looked at, never driven. + +import type { Subagent } from './subagents.js'; + +/** How a visitor is getting on, from the latest entries of its transcript. */ +export type VisitorStatus = 'working' | 'needs_input' | 'done'; + +/** Where a visitor's session runs. */ +export type VisitorApp = 'desktop' | 'cli' | 'ide' | 'other'; + +export interface Visitor { + /** Its session id. */ + id: string; + /** Its title in the app, else its first prompt, else its folder. */ + title: string; + app: VisitorApp; + /** The folder it runs in, as its last two parts ("Programer/my-site"). */ + folder: string; + branch?: string; + status: VisitorStatus; + /** Its latest tool call, or what it last said. */ + activity?: string; + /** The model its latest message came from. */ + model?: string; + /** When its transcript (or one of its subagents') was last written (ms). */ + seenAt: number; + /** Its folder's repository is no floor's, so it waits in the lobby (the building's first floor). */ + lobby: boolean; + /** The subagents it has running, most recent first, at most VISITOR_SUBAGENTS of them. */ + subagents: Subagent[]; + /** How many it has running in all. */ + subagentCount: number; +} + +/** A visitor shows at most this many of its subagents round it; the rest are a count on its card. */ +export const VISITOR_SUBAGENTS = 8; + +/** The visitors on one floor. `off`: the office was started with --no-visitors. */ +export interface VisitorsState { + visitors: Visitor[]; + off?: boolean; +} + +export type VisitorsServerMsg = { t: 'visitors'; state: VisitorsState }; diff --git a/src/shared/protocol/workers.ts b/src/shared/protocol/workers.ts index 1f83346fb..473b0e082 100644 --- a/src/shared/protocol/workers.ts +++ b/src/shared/protocol/workers.ts @@ -1,6 +1,7 @@ // Workers at their desks: what they are, their worktrees and their terminals. import type { AgentEffort, AgentProvider } from './agents.js'; +import type { Subagent } from './subagents.js'; import type { Usage } from './usage.js'; export type WorkerStatus = @@ -88,6 +89,8 @@ export interface WorkerInfo { task?: WorkerTask; /** Reported session tokens and cost, when the provider supplies them (agents only). */ usage?: Usage; + /** The subagents its session has running (Claude Code's SubagentStart and SubagentStop hooks, see server/subagents.ts). */ + subagents?: Subagent[]; /** Who last typed into its terminal (or sent it a prompt), and when. */ lastInput?: { by: string; at: number }; /** The meeting it was called to, for a worker at the meeting room's table (see Meeting). */ diff --git a/tests/client-store.test.ts b/tests/client-store.test.ts index adf83062c..c98d2e7f1 100644 --- a/tests/client-store.test.ts +++ b/tests/client-store.test.ts @@ -74,10 +74,10 @@ const welcome = () => }); /** What a floor you arrive on fires, in order. */ -const FLOOR_TOPICS = ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'meeting', 'decor', 'floorPlan', 'services', 'dog', 'jukebox', 'whiteboard', 'drawing', 'cabinet', 'cabinetFrame', 'ball', 'cars', 'jail']; +const FLOOR_TOPICS = ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'meeting', 'decor', 'floorPlan', 'services', 'dog', 'jukebox', 'whiteboard', 'drawing', 'cabinet', 'cabinetFrame', 'ball', 'cars', 'jail', 'visitors']; /** Every topic, to listen for them all. */ -const TOPICS = ['peers', 'workers', 'issues', 'pulls', 'chat', 'project', 'screens', 'team', 'upgrade', 'services', 'decor', 'floorPlan', 'usage', 'limits', 'queue', 'me', 'accounts', 'signins', 'notify', 'machine', 'floors', 'floor', 'projectsDir', 'repos', 'dog', 'jukebox', 'sky', 'theme', 'map', 'leaveOnMerge', 'whiteboard', 'drawing', 'cabinet', 'cabinetFrame', 'meeting', 'prompts', 'ball', 'cars', 'jail'] as const; +const TOPICS = ['peers', 'workers', 'issues', 'pulls', 'chat', 'project', 'screens', 'team', 'upgrade', 'services', 'decor', 'floorPlan', 'usage', 'limits', 'queue', 'me', 'accounts', 'signins', 'notify', 'machine', 'floors', 'floor', 'projectsDir', 'repos', 'dog', 'jukebox', 'sky', 'theme', 'map', 'leaveOnMerge', 'whiteboard', 'drawing', 'cabinet', 'cabinetFrame', 'meeting', 'prompts', 'ball', 'cars', 'jail', 'visitors'] as const; /** Every message the store takes in (and one it doesn't), and the topics it fires, in the order it has always fired them. */ const RUN: [ServerMsg, string[]][] = [ @@ -120,6 +120,7 @@ const RUN: [ServerMsg, string[]][] = [ [msg({ t: 'dog', dog: null }), ['dog']], [msg({ t: 'ball', ball: {} }), ['ball']], [msg({ t: 'cars', cars: [{ x: 0, z: 0, rotY: 0, speed: 0, steer: 0 }] }), ['cars']], + [msg({ t: 'visitors', state: { visitors: [] } }), ['visitors']], [msg({ t: 'car.move', car: 0, x: 1, z: 1, rotY: 0, speed: 1, steer: 0 }), []], [msg({ t: 'sky', state: { hour: 2 } }), ['sky']], [msg({ t: 'theme', state: { pick: 'none', active: null } }), ['theme']], @@ -225,7 +226,7 @@ test('what the browser remembers keeps its keys and shapes', () => { test("the store's keys are its state, as window.__office shows them", () => { // As the office had them before its store was split into slices: methods and the slices aren't among them. - assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); + assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'visitors', 'whiteboard', 'workers', 'you']); }); test('a new store starts every field where it always has', async () => { @@ -249,7 +250,7 @@ test('a new store starts every field where it always has', async () => { dog: null, dogStart: 0, jukebox: { on: false, track: JUKEBOX_TUNES[0].id, startedAt: 0, elapsed: 0, since: 0 }, clock: '', whiteboard: [], drawing: [], cabinet: { player: null, scores: [] }, cabinetFrame: null, ball: {}, cars: parked(), carsAt: [], jail: { prisoners: [], bones: 0 }, - team: null, accounts: null, signins: null, + team: null, accounts: null, signins: null, visitors: { visitors: [] }, }, ); }); diff --git a/tests/server-dispatch.test.ts b/tests/server-dispatch.test.ts index 2331c41fc..7831e8caf 100644 --- a/tests/server-dispatch.test.ts +++ b/tests/server-dispatch.test.ts @@ -267,7 +267,7 @@ test('welcomes a browser and dispatches what it sends', async () => { assert.equal(ada?.name, 'Ada'); assert.equal(ada?.color, '#ff8a5b'); assert.equal(ada?.floor, floor.id); - assert.deepEqual(Object.keys(welcome).slice(-17), ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'decor', 'plan', 'services', 'dog', 'ball', 'cars', 'jail', 'jukebox', 'whiteboard', 'meeting', 'cabinet']); + assert.deepEqual(Object.keys(welcome).slice(-18), ['floor', 'project', 'workers', 'issues', 'pulls', 'queue', 'decor', 'plan', 'services', 'dog', 'ball', 'cars', 'jail', 'jukebox', 'whiteboard', 'meeting', 'cabinet', 'visitors']); a.send({ t: 'ping', at: 42 }); const pong = await a.take('pong'); diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts new file mode 100644 index 000000000..a256fbcab --- /dev/null +++ b/tests/subagents.test.ts @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { WorkerInfo } from '../src/shared/protocol.js'; +import { SUBAGENT_STALE_MS, liveSubagents } from '../src/shared/protocol.js'; +import { MAX_SUBAGENTS, noteSubagent, subagentMeta } from '../src/server/subagents.js'; +import { claude } from '../src/server/providers/claude.js'; + +function info(): WorkerInfo { + return { id: 'w1', kind: 'agent', provider: 'claude', deskId: 'desk-1', name: 'Pixel', color: '#fff', status: 'working', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [] }; +} + +/** A session's transcript, with its subagents' meta files beside it as Claude Code keeps them. */ +function session(t: test.TestContext): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ao-subagents-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const transcript = path.join(dir, 'sess-1.jsonl'); + writeFileSync(transcript, ''); + const subs = path.join(dir, 'sess-1', 'subagents'); + mkdirSync(path.join(subs, 'workflows', 'wf_1'), { recursive: true }); + writeFileSync(path.join(subs, 'agent-a1.meta.json'), JSON.stringify({ agentType: 'Explore', description: 'Find the login code' })); + writeFileSync(path.join(subs, 'workflows', 'wf_1', 'agent-b2.meta.json'), JSON.stringify({ agentType: 'workflow-subagent', description: 'verify:licence', workflowPhase: 'Verify' })); + return transcript; +} + +test('a subagent stands by its worker from SubagentStart to SubagentStop, with what it was asked and its latest tool', (t) => { + const transcript = session(t); + const w = info(); + assert.equal(noteSubagent(w, 'SubagentStart', { agent_id: 'a1', agent_type: 'Explore' }, { transcript, now: 1000 }), true); + assert.deepEqual(w.subagents, [{ id: 'a1', type: 'Explore', title: 'Find the login code', phase: undefined, since: 1000, seenAt: 1000 }]); + // Its own tool calls come in on the worker's hooks, with its agent_id. + assert.equal(noteSubagent(w, 'PreToolUse', { agent_id: 'a1', tool_name: 'Grep' }, { transcript, activity: 'Grep: login', now: 2000 }), true); + assert.equal(w.subagents?.[0].activity, 'Grep: login'); + assert.equal(w.subagents?.[0].seenAt, 2000); + // Nothing new to show: not worth a message. + assert.equal(noteSubagent(w, 'PostToolUse', { agent_id: 'a1' }, { transcript, now: 2500 }), false); + // The worker's own events (no agent_id) leave its subagents be. + assert.equal(noteSubagent(w, 'PreToolUse', { tool_name: 'Bash' }, { transcript, activity: 'Bash: ls', now: 2600 }), false); + assert.equal(w.subagents?.length, 1); + assert.equal(noteSubagent(w, 'SubagentStop', { agent_id: 'a1', agent_type: 'Explore' }, { transcript, now: 3000 }), true); + assert.equal(w.subagents, undefined); + assert.equal(noteSubagent(w, 'SubagentStop', { agent_id: 'a1' }, { transcript, now: 3100 }), false); +}); + +test("a workflow's agents are read from its run's folder, and one first heard of mid-run is taken in", (t) => { + const transcript = session(t); + const w = info(); + // The office restarted while it ran: its SubagentStart never came, its next tool call does. + assert.equal(noteSubagent(w, 'PreToolUse', { agent_id: 'b2', agent_type: 'workflow-subagent' }, { transcript, activity: 'Read: LICENSE', now: 10 }), true); + assert.deepEqual(w.subagents, [{ id: 'b2', type: 'workflow-subagent', title: 'verify:licence', phase: 'Verify', activity: 'Read: LICENSE', since: 10, seenAt: 10 }]); + assert.equal(subagentMeta(transcript, 'b2')?.workflowPhase, 'Verify'); + // SubagentStop names its transcript, and the meta file next to it is read first. + const own = path.join(path.dirname(transcript), 'sess-1', 'subagents', 'agent-a1.jsonl'); + assert.equal(subagentMeta(undefined, 'a1', own)?.description, 'Find the login code'); +}); + +test('a new session has none, ones not heard from in a while are gone, ids are never paths, and there is a cap', (t) => { + const transcript = session(t); + const w = info(); + noteSubagent(w, 'SubagentStart', { agent_id: 'a1', agent_type: 'Explore' }, { transcript, now: 0 }); + assert.equal(noteSubagent(w, 'SessionStart', { source: 'clear' }, { now: 1 }), true); + assert.equal(w.subagents, undefined); + noteSubagent(w, 'SubagentStart', { agent_id: 'a1', agent_type: 'Explore' }, { transcript, now: 0 }); + // Stopped mid-turn (Esc), it never said it was done. + assert.equal(noteSubagent(w, 'UserPromptSubmit', {}, { now: SUBAGENT_STALE_MS + 1 }), true); + assert.equal(w.subagents, undefined); + assert.equal(noteSubagent(w, 'SubagentStart', { agent_id: '../../etc/passwd', agent_type: 'x' }, { transcript, now: 5 }), false); + assert.equal(w.subagents, undefined); + for (let i = 0; i < MAX_SUBAGENTS + 5; i++) noteSubagent(w, 'SubagentStart', { agent_id: `x${i}`, agent_type: 'general-purpose' }, { now: 10 + i }); + assert.equal(w.subagents?.length, MAX_SUBAGENTS); + assert.equal(w.subagents?.[0].title, undefined); + assert.deepEqual(liveSubagents(w.subagents, 10 + MAX_SUBAGENTS).slice(0, 2).map((s) => s.id), [`x${MAX_SUBAGENTS - 1}`, `x${MAX_SUBAGENTS - 2}`]); +}); + +test("Claude Code workers are hooked for SubagentStart and SubagentStop, and the worker's card hears about them", (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ao-hooks-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const setup = claude.prepare!({ dataDir: dir, dshProfile: 'acp' }); + const hooks = JSON.parse(readFileSync(setup.settings, 'utf8')).hooks; + for (const event of ['SubagentStart', 'SubagentStop', 'SessionStart', 'PreToolUse', 'Stop']) assert.ok(hooks[event], `${event} is hooked`); + assert.match(hooks.SubagentStart[0].hooks[0].command, /event=SubagentStart/); + + const w = info(); + let emitted = 0; + const statuses: string[] = []; + const h = { + info: w, + state: undefined, + running: true, + bootBlocked: false, + leftNeedsInputAt: 0, + failStreak: 0, + tracker: { transcript: undefined }, + setStatus: (s: string) => { + statuses.push(s); + w.status = s as WorkerInfo['status']; + emitted++; + }, + emit: () => void emitted++, + persist: () => {}, + notePrompt: () => {}, + noteTool: () => {}, + notePr: () => {}, + clearTask: () => {}, + scheduleScan: () => {}, + prompt: () => undefined, + } as any; + assert.equal(claude.hook!.handle(h, 'SubagentStart', { session_id: 's1', agent_id: 'a9', agent_type: 'Plan' }), true); + assert.equal(w.subagents?.[0].type, 'Plan'); + assert.equal(emitted, 1); + // A subagent's events never end its worker's turn. + assert.equal(claude.hook!.handle(h, 'SubagentStop', { session_id: 's1', agent_id: 'a9', agent_type: 'Plan' }), true); + assert.equal(w.subagents, undefined); + assert.equal(w.status, 'working'); + assert.deepEqual(statuses, []); + assert.equal(emitted, 2); +}); diff --git a/tests/visitors.test.ts b/tests/visitors.test.ts new file mode 100644 index 000000000..7d734271b --- /dev/null +++ b/tests/visitors.test.ts @@ -0,0 +1,168 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { VisitorsState } from '../src/shared/protocol.js'; +import { appOf, sessionOf, turnOf } from '../src/server/visitors/transcript.js'; +import { SessionScanner } from '../src/server/visitors/scan.js'; +import { Visitors, claudeProjectsDir, floorFor, shortFolder, visitorOf } from '../src/server/visitors/index.js'; + +const NOW = Date.UTC(2026, 10, 9, 12); +const lines = (...es: object[]) => es.map((e) => JSON.stringify(e)).join('\n') + '\n'; +const assistant = (content: object[], stop_reason: string | null, extra: object = {}) => ({ type: 'assistant', message: { model: 'claude-opus-5-5', content, stop_reason }, ...extra }); +const user = (content: unknown, extra: object = {}) => ({ type: 'user', message: { role: 'user', content }, ...extra }); +const toolUse = (name: string, input: object) => ({ type: 'tool_use', id: 't1', name, input }); + +test("how a session is getting on, from its transcript's last messages", () => { + assert.deepEqual(turnOf([user('fix it'), assistant([toolUse('Bash', { command: 'npm test' })], 'tool_use')]), { status: 'working', activity: 'Bash: npm test', model: 'claude-opus-5-5', found: true }); + // A tool's result came last: still at work, on the call before it. + assert.equal(turnOf([assistant([toolUse('Read', { file_path: '/a.ts' })], 'tool_use'), user([{ type: 'tool_result', tool_use_id: 't1', content: 'x' }])]).activity, 'Read: /a.ts'); + assert.equal(turnOf([assistant([toolUse('AskUserQuestion', { questions: [] })], 'tool_use')]).status, 'needs_input'); + assert.deepEqual(turnOf([assistant([{ type: 'text', text: 'All done\nhere.' }], 'end_turn'), { type: 'system', subtype: 'stop_hook_summary' }]), { status: 'done', activity: 'All done here.', model: 'claude-opus-5-5', found: true }); + assert.equal(turnOf([assistant([{ type: 'text', text: 'x' }], 'tool_use'), user('[Request interrupted by user]')]).status, 'done'); + assert.equal(turnOf([user('go')]).status, 'working'); + assert.equal(turnOf([assistant([toolUse('mcp__Claude_Browser__browser_batch', {})], 'tool_use')]).activity, 'browser_batch'); + // A subagent's transcript is all off the main thread. + const side = [assistant([toolUse('Grep', { pattern: 'login' })], 'tool_use', { isSidechain: true })]; + assert.equal(turnOf(side).found, false); + assert.equal(turnOf(side, true).activity, 'Grep: login'); + assert.equal(turnOf([]).found, false); +}); + +test("what a session is called, where it runs, and in which app", () => { + const head = [ + { type: 'queue-operation', operation: 'enqueue' }, + user('not a prompt', { entrypoint: 'claude-desktop', cwd: '/Users/o/Work/site', gitBranch: 'HEAD' }), + user('Make the hero section load faster on phones', { entrypoint: 'claude-desktop', cwd: '/Users/o/Work/site' }), + ]; + assert.deepEqual(sessionOf(head, head), { app: 'desktop', prompt: 'Make the hero section load faster on phones', cwd: '/Users/o/Work/site' }); + const tail = [{ type: 'custom-title', customTitle: 'Old name' }, { type: 'custom-title', customTitle: 'Hero speed' }, user('ok', { gitBranch: 'dev' })]; + assert.equal(sessionOf(head, tail).title, 'Hero speed'); + assert.equal(sessionOf(head, tail).branch, 'dev'); + assert.equal(sessionOf([{ type: 'summary', summary: 'Login fix' }], []).title, 'Login fix'); + assert.equal(appOf('cli'), 'cli'); + assert.equal(appOf('claude-vscode'), 'ide'); + assert.equal(appOf('sdk-cli'), 'other'); + assert.equal(shortFolder('/Users/o/Work/site'), 'Work/site'); + assert.equal(claudeProjectsDir({ CLAUDE_CONFIG_DIR: '/x/claude' }), path.join('/x/claude', 'projects')); +}); + +/** Claude Code's projects folder, as it lays it out, with files dated `ago` ms before NOW. */ +function projects(t: test.TestContext) { + const root = mkdtempSync(path.join(os.tmpdir(), 'ao-visitors-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const put = (rel: string, body: string, ago: number) => { + const file = path.join(root, rel); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, body); + const at = (NOW - ago) / 1000; + utimesSync(file, at, at); + }; + return { root, put }; +} + +const UUID = (n: number) => `0000000${n}-aaaa-bbbb-cccc-000000000000`; + +/** Every file under `dir`, with what's in it and when it was written. */ +function snapshot(dir: string): Record { + const out: Record = {}; + const walk = (d: string) => { + for (const name of readdirSync(d)) { + const p = path.join(d, name); + const st = statSync(p); + if (st.isDirectory()) walk(p); + else out[path.relative(dir, p)] = `${st.mtimeMs}|${createHash('sha1').update(readFileSync(p)).digest('hex')}`; + } + }; + walk(dir); + return out; +} + +test('the scanner finds the sessions in use and their subagents at work, and never writes a byte', (t) => { + const { root, put } = projects(t); + const cwd = '/Users/o/Work/site'; + // In use: written a minute ago, with one subagent at work, one finished and one long quiet. + put(`-Users-o-Work-site/${UUID(1)}.jsonl`, lines({ type: 'custom-title', customTitle: 'Hero speed' }, user('go', { entrypoint: 'claude-desktop', cwd }), assistant([toolUse('Agent', { description: 'Measure LCP' })], 'tool_use')), 60_000); + put(`-Users-o-Work-site/${UUID(1)}/subagents/agent-a1.jsonl`, lines(assistant([toolUse('Bash', { command: 'lighthouse' })], 'tool_use', { isSidechain: true })), 5_000); + put(`-Users-o-Work-site/${UUID(1)}/subagents/agent-a1.meta.json`, JSON.stringify({ agentType: 'general-purpose', description: 'Measure LCP' }), 5_000); + put(`-Users-o-Work-site/${UUID(1)}/subagents/agent-a2.jsonl`, lines(assistant([{ type: 'text', text: 'ok' }], 'end_turn', { isSidechain: true })), 5_000); + put(`-Users-o-Work-site/${UUID(1)}/subagents/agent-a2.meta.json`, JSON.stringify({ agentType: 'Explore', finished: true }), 5_000); + put(`-Users-o-Work-site/${UUID(1)}/subagents/agent-a3.jsonl`, lines(assistant([{ type: 'text', text: 'ok' }], 'end_turn', { isSidechain: true })), 30 * 60_000); + // Its own turn ended an hour ago, but a workflow it started is still at work. + put(`-Users-o-Work-api/${UUID(2)}.jsonl`, lines(user('run the audit', { entrypoint: 'cli', cwd: '/Users/o/Work/api' }), assistant([{ type: 'text', text: 'Started.' }], 'end_turn')), 60 * 60_000); + put(`-Users-o-Work-api/${UUID(2)}/subagents/workflows/wf_1/agent-b1.jsonl`, lines(assistant([toolUse('Read', { file_path: 'LICENSE' })], 'tool_use', { isSidechain: true })), 2_000); + put(`-Users-o-Work-api/${UUID(2)}/subagents/workflows/wf_1/agent-b1.meta.json`, JSON.stringify({ agentType: 'workflow-subagent', description: 'verify:licence', workflowPhase: 'Verify' }), 2_000); + // Done an hour ago, with nothing at work: gone home. And the office's own worker. + put(`-Users-o-Work-api/${UUID(3)}.jsonl`, lines(user('hi', { cwd: '/Users/o/Work/api' }), assistant([{ type: 'text', text: 'hi' }], 'end_turn')), 60 * 60_000); + put(`-Users-o-Work-site/${UUID(4)}.jsonl`, lines(user('worker task', { cwd }), assistant([toolUse('Edit', {})], 'tool_use')), 1_000); + // Not a session's transcript at all. + put('-Users-o-Work-site/memory/notes.jsonl', 'x\n', 1_000); + const before = snapshot(root); + + const scanner = new SessionScanner(root); + const seen = scanner.scan((id) => id === UUID(4), NOW).sort((a, b) => a.id.localeCompare(b.id)); + assert.deepEqual(seen.map((s) => s.id), [UUID(1), UUID(2)]); + const [site, api] = seen; + assert.equal(site.facts.title, 'Hero speed'); + assert.equal(site.facts.app, 'desktop'); + assert.equal(site.turn.activity, 'Agent: Measure LCP'); + assert.deepEqual(site.subagents.map((s) => [s.id, s.type, s.title, s.activity]), [['a1', 'general-purpose', 'Measure LCP', 'Bash: lighthouse']]); + assert.equal(api.turn.status, 'done'); + assert.deepEqual(api.subagents.map((s) => [s.id, s.phase, s.activity]), [['b1', 'Verify', 'Read: LICENSE']]); + // Waiting on its workflow, it's at work too. + const v = visitorOf(api, true); + assert.equal(v.status, 'working'); + assert.equal(v.activity, '1 subagent at work'); + assert.equal(v.title, 'run the audit'); + assert.equal(v.lobby, true); + // Again, from what it kept, and with nothing changed on disk. + assert.equal(scanner.scan((id) => id === UUID(4), NOW + 1000).length, 2); + assert.deepEqual(snapshot(root), before); +}); + +test('a visitor goes to the floor it works in, by folder or by repository, else to the lobby', () => { + const floors = [ + { id: 'lobby-floor', dir: '/srv/office/acme/web', repo: 'acme/web' }, + { id: 'site', dir: '/srv/office/o/site', repo: 'Omar-Tnzxo/omar-agha-site' }, + ]; + assert.deepEqual(floorFor('/srv/office/o/site/src', undefined, floors), { floor: 'site', lobby: false }); + assert.deepEqual(floorFor('/Users/o/Work/portfolio', 'omar-tnzxo/OMAR-AGHA-SITE', floors), { floor: 'site', lobby: false }); + assert.deepEqual(floorFor('/Users/o/Work/other', 'o/other', floors), { floor: 'lobby-floor', lobby: true }); + assert.deepEqual(floorFor(undefined, undefined, floors), { floor: 'lobby-floor', lobby: true }); + // A worker's own worktree: the office's, never a visitor. + assert.equal(floorFor('/srv/office/o/site/.agent-office/worktrees/w1', 'Omar-Tnzxo/omar-agha-site', floors), undefined); + assert.equal(floorFor('/x', undefined, []), undefined); +}); + +test("the office tells each floor its visitors when they change, never counts its own workers, and can be switched off", async (t) => { + const { root, put } = projects(t); + const now = Date.now(); + const dir = mkdtempSync(path.join(os.tmpdir(), 'ao-floor-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + put(`p/${UUID(5)}.jsonl`, lines(user('visit', { entrypoint: 'cli', cwd: dir }), assistant([toolUse('Bash', { command: 'ls' })], 'tool_use')), 0); + put(`p/${UUID(6)}.jsonl`, lines(user('worker', { cwd: dir }), assistant([toolUse('Bash', { command: 'ls' })], 'tool_use')), 0); + for (const f of [`p/${UUID(5)}.jsonl`, `p/${UUID(6)}.jsonl`]) utimesSync(path.join(root, f), now / 1000, now / 1000); + const sent: [string, VisitorsState][] = []; + const visitors = new Visitors( + { root }, + { + floors: () => [{ id: 'f1', dir }, { id: 'f2', dir: '/nowhere' }], + workerSessions: () => new Set([UUID(6)]), + watched: () => true, + send: (floor, state) => void sent.push([floor, state]), + }, + ); + await visitors.look(now); + assert.deepEqual(sent.map(([f, s]) => [f, s.visitors.map((v) => [v.id, v.app, v.status, v.lobby])]), [ + ['f1', [[UUID(5), 'cli', 'working', false]]], + ['f2', []], + ]); + assert.equal(visitors.state('f1').visitors[0].activity, 'Bash: ls'); + await visitors.look(now + 1); + assert.equal(sent.length, 2, 'nothing changed, nothing sent'); + const off = new Visitors({ root, off: true }, { floors: () => [], workerSessions: () => new Set(), watched: () => true, send: () => assert.fail('sent while off') }); + await off.look(now); + assert.deepEqual(off.state('f1'), { visitors: [], off: true }); +});