Files
codex-office/src/sim/director.ts
T
JianMiauandClaude Fable 5 7d9d477166 建立 Codex Office 虛擬辦公室(模擬 + 實況雙模式)
摘要:
以 claude-office 為基底改造的 Codex CLI 版虛擬辦公室:終端機綠 + 冷灰主題、
>_ 游標標誌,實況模式改為監看 ~/.codex/sessions/ 的 rollout transcripts,
依 cwd 將 session 分組成專案顯示。

根本原因:
既有 claude-office 只能監看 Claude Code 活動,Codex CLI 的 session 格式
(rollout JSONL:session_meta / turn_context / event_msg / response_item)
與存放結構(YYYY/MM/DD 日期目錄)完全不同,需要獨立的轉譯器。

影響:
新專案,與 claude-office 並存:web 5182(0.0.0.0)、watch 5181(僅 127.0.0.1),
與 5179/5180 不衝突,可同時常駐 PM2。

修法:
- server/watch.mjs 重寫:遞迴掃描日期目錄、tail 新增位元組、
  由 session_meta/turn_context 的 cwd 分組專案,
  custom_tool_call/function_call/MCP → 工具事件,agent_message/task_complete → 回覆
- 主題改造:constants 配色(石墨綠)、Sora/Manrope/JetBrains Mono 字型、
  >_ 閃爍游標標誌、agents 改為 Codex/Sol/Nova/Mini、工具改為 exec/apply_patch 等
- live.ts 新增 thinking 事件(對應 reasoning),port 5181

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:02:32 +08:00

446 lines
13 KiB
TypeScript

import { useOfficeStore } from "@/store/office-store";
import { ENTRANCE, MAX_MAIN_AGENTS, MAX_SUB_AGENTS } from "@/lib/constants";
import { DESK_SLOTS, HOT_DESK_SLOTS, LOUNGE_ANCHORS } from "@/lib/positions";
import {
AGENT_MODELS,
AGENT_NAMES,
DONE_PHRASES,
MEETING_PHRASES,
SUB_AGENT_KINDS,
SUB_DONE_PHRASES,
SUB_TOOLS,
TASK_NAMES,
TOOLS,
pick,
rand,
} from "@/lib/phrases";
type Phase =
| "arriving"
| "idle"
| "thinking"
| "tooling"
| "waiting_subs"
| "speaking"
| "error"
| "lounge_go"
| "lounge_stay"
| "lounge_back"
| "meeting"
| "sub_arriving"
| "sub_working"
| "sub_reporting"
| "sub_leaving";
interface Brain {
phase: Phase;
timer: number;
toolsLeft: number;
loungeAnchor: number;
taskName: string | null;
}
interface MeetingSession {
id: string;
agentIds: string[];
timer: number;
speakTimer: number;
speakerIdx: number;
gathered: boolean;
}
let subSeq = 0;
let meetingSeq = 0;
/**
* The simulation director: a per-agent finite-state machine plus a global
* meeting scheduler. Everything mutates the world through store actions only,
* so a real Claude Code event feed could replace this file wholesale.
*/
export class Director {
private brains = new Map<string, Brain>();
private meeting: MeetingSession | null = null;
private meetingCooldown = 20;
private arrivalQueue: string[] = [];
private arrivalTimer = 0.5;
constructor() {
this.arrivalQueue = AGENT_NAMES.slice(0, 4);
}
private get store() {
return useOfficeStore.getState();
}
update(dt: number) {
this.handleArrivals(dt);
this.updateMeetingScheduler(dt);
const S = this.store;
for (const [id, brain] of this.brains) {
const agent = S.agents.get(id);
if (!agent) {
this.brains.delete(id);
continue;
}
brain.timer -= dt;
this.stepAgent(id, brain, dt);
}
}
/* ── public controls ── */
/** Manually assign a task from the UI panel. */
assignTask(id: string): boolean {
const brain = this.brains.get(id);
const agent = this.store.agents.get(id);
if (!brain || !agent || brain.phase !== "idle" || agent.movement) return false;
this.beginTask(id, brain);
return true;
}
/** Spawn one more main agent (header button). */
hireAgent(): boolean {
const S = this.store;
const mains = [...S.agents.values()].filter((a) => a.role !== "subagent");
if (mains.length + this.arrivalQueue.length >= MAX_MAIN_AGENTS) return false;
const used = new Set(mains.map((a) => a.name));
const name = AGENT_NAMES.find((n) => !used.has(n) && !this.arrivalQueue.includes(n));
if (!name) return false;
this.arrivalQueue.push(name);
return true;
}
/* ── arrivals ── */
private handleArrivals(dt: number) {
if (this.arrivalQueue.length === 0) return;
this.arrivalTimer -= dt;
if (this.arrivalTimer > 0) return;
this.arrivalTimer = rand(0.9, 1.6);
const S = this.store;
const name = this.arrivalQueue.shift()!;
const occupied = new Set(
[...S.agents.values()].filter((a) => a.homeZone === "desk").map((a) => a.homeSlot),
);
const slot = DESK_SLOTS.findIndex((_, i) => !occupied.has(i));
if (slot === -1) return;
const id = `main-${name.toLowerCase()}`;
const role = S.agents.size === 0 ? "lead" : "agent";
S.spawnAgent({ id, name, role, model: AGENT_MODELS[name] ?? "claude-sonnet-5", homeSlot: slot });
S.addEvent("🚪", `${name} 進入辦公室`);
S.startWalk(id, DESK_SLOTS[slot], "desk", { arriveStatus: "idle" });
this.brains.set(id, {
phase: "arriving",
timer: 0,
toolsLeft: 0,
loungeAnchor: -1,
taskName: null,
});
}
/* ── main agent FSM ── */
private beginTask(id: string, brain: Brain) {
const S = this.store;
const task = pick(TASK_NAMES);
brain.taskName = task;
brain.phase = "thinking";
brain.timer = rand(2, 4);
S.setStatus(id, "thinking");
const agent = S.agents.get(id);
if (agent) S.addEvent("📋", `${agent.name} 接下任務「${task}」`);
}
private stepAgent(id: string, brain: Brain, _dt: number) {
const S = this.store;
const agent = S.agents.get(id)!;
switch (brain.phase) {
case "arriving": {
if (!agent.movement && agent.zone === agent.homeZone) {
brain.phase = "idle";
brain.timer = rand(1.5, 5);
}
break;
}
case "idle": {
if (this.meeting?.agentIds.includes(id)) break;
if (brain.timer > 0 || agent.movement) break;
const roll = Math.random();
if (roll < 0.62) {
this.beginTask(id, brain);
} else if (roll < 0.78 && agent.zone === "desk") {
// coffee break
const usedAnchors = new Set(
[...this.brains.values()].map((b) => b.loungeAnchor).filter((i) => i >= 0),
);
const anchor = LOUNGE_ANCHORS.findIndex((_, i) => !usedAnchors.has(i));
if (anchor >= 0) {
brain.loungeAnchor = anchor;
brain.phase = "lounge_go";
S.startWalk(id, LOUNGE_ANCHORS[anchor], "lounge", { arriveStatus: "idle" });
S.addEvent("☕", `${agent.name} 去休息區倒咖啡`);
} else {
brain.timer = rand(2, 5);
}
} else if (roll < 0.83) {
brain.phase = "error";
brain.timer = rand(2.5, 3.5);
S.setStatus(id, "error");
S.addEvent("⚠️", `${agent.name} 遇到錯誤,重試中`);
} else {
brain.timer = rand(2, 6);
}
break;
}
case "thinking": {
if (brain.timer > 0) break;
brain.phase = "tooling";
brain.toolsLeft = 2 + Math.floor(Math.random() * 3);
brain.timer = 0;
S.setStatus(id, "tool_calling");
break;
}
case "tooling": {
if (brain.timer > 0) break;
if (brain.toolsLeft > 0) {
brain.toolsLeft -= 1;
brain.timer = rand(1.4, 2.6);
S.setTool(id, { name: pick(TOOLS), startedAt: S.clock });
break;
}
S.setTool(id, null);
// decide whether to delegate to subagents
const subCount = [...S.agents.values()].filter((a) => a.role === "subagent").length;
const wantSubs = Math.random() < 0.55 ? 1 + Math.floor(Math.random() * 2) : 0;
const canSpawn = Math.min(wantSubs, MAX_SUB_AGENTS - subCount);
if (canSpawn > 0) {
for (let i = 0; i < canSpawn; i++) this.spawnSubagent(id);
brain.phase = "waiting_subs";
S.setStatus(id, "thinking");
} else {
this.finishTask(id, brain);
}
break;
}
case "waiting_subs": {
if (agent.childIds.length === 0) {
this.finishTask(id, brain);
}
break;
}
case "speaking": {
if (brain.timer > 0) break;
S.setSpeech(id, null);
S.setStatus(id, "idle");
brain.phase = "idle";
brain.timer = rand(3, 8);
break;
}
case "error": {
if (brain.timer > 0) break;
S.setStatus(id, "idle");
brain.phase = "idle";
brain.timer = rand(2, 5);
break;
}
case "lounge_go": {
if (!agent.movement && agent.zone === "lounge") {
brain.phase = "lounge_stay";
brain.timer = rand(5, 10);
}
break;
}
case "lounge_stay": {
if (brain.timer > 0) break;
brain.phase = "lounge_back";
brain.loungeAnchor = -1;
S.walkHome(id);
break;
}
case "lounge_back": {
if (!agent.movement && agent.zone === agent.homeZone) {
brain.phase = "idle";
brain.timer = rand(2, 6);
}
break;
}
case "meeting":
// handled by the meeting scheduler
break;
/* ── subagent FSM ── */
case "sub_arriving": {
if (!agent.movement && agent.zone === "hotDesk") {
brain.phase = "sub_working";
brain.toolsLeft = 3 + Math.floor(Math.random() * 4);
brain.timer = 0;
S.setStatus(id, "tool_calling");
}
break;
}
case "sub_working": {
if (brain.timer > 0) break;
if (brain.toolsLeft > 0) {
brain.toolsLeft -= 1;
brain.timer = rand(1.2, 2.2);
S.setTool(id, { name: pick(SUB_TOOLS), startedAt: S.clock });
break;
}
S.setTool(id, null);
brain.phase = "sub_reporting";
brain.timer = rand(1.8, 2.6);
S.setStatus(id, "speaking");
S.setSpeech(id, pick(SUB_DONE_PHRASES));
break;
}
case "sub_reporting": {
if (brain.timer > 0) break;
S.setSpeech(id, null);
S.addEvent("↩️", `${agent.name} 回報完畢,離開辦公室`);
if (agent.parentId) S.removeLink(agent.parentId, id);
brain.phase = "sub_leaving";
S.startWalk(id, { ...ENTRANCE }, "corridor", { despawnOnArrive: true });
break;
}
case "sub_leaving":
break;
}
}
private finishTask(id: string, brain: Brain) {
const S = this.store;
const agent = S.agents.get(id);
if (!agent) return;
brain.phase = "speaking";
brain.timer = rand(2.5, 4);
S.setStatus(id, "speaking");
S.setSpeech(id, pick(DONE_PHRASES));
S.addEvent("✅", `${agent.name} 完成「${brain.taskName ?? "任務"}」`);
brain.taskName = null;
}
private spawnSubagent(parentId: string) {
const S = this.store;
const parent = S.agents.get(parentId);
if (!parent) return;
const occupied = new Set(
[...S.agents.values()].filter((a) => a.homeZone === "hotDesk").map((a) => a.homeSlot),
);
const slot = HOT_DESK_SLOTS.findIndex((_, i) => !occupied.has(i));
if (slot === -1) return;
const kind = pick(SUB_AGENT_KINDS);
const id = `sub-${kind.toLowerCase()}-${subSeq++}`;
const name = `${kind}-${subSeq}`;
S.spawnAgent({ id, name, role: "subagent", model: "claude-haiku-4-5", homeSlot: slot, parentId });
S.addEvent("✨", `${parent.name} 派出 subagent ${name}`);
S.startWalk(id, HOT_DESK_SLOTS[slot], "hotDesk", { arriveStatus: "tool_calling" });
S.addLink({ sourceId: parentId, targetId: id, strength: 0.6, kind: "spawn" });
this.brains.set(id, {
phase: "sub_arriving",
timer: 0,
toolsLeft: 0,
loungeAnchor: -1,
taskName: null,
});
}
/* ── meetings ── */
private updateMeetingScheduler(dt: number) {
const S = this.store;
if (this.meeting) {
const m = this.meeting;
m.timer -= dt;
if (!m.gathered) {
const allSeated = m.agentIds.every((id) => {
const a = S.agents.get(id);
return a && !a.movement && a.zone === "meeting";
});
if (allSeated) {
m.gathered = true;
m.speakTimer = 0;
}
} else {
m.speakTimer -= dt;
if (m.speakTimer <= 0) {
// rotate speaker
const prev = m.agentIds[m.speakerIdx % m.agentIds.length];
S.setSpeech(prev, null);
S.setStatus(prev, "idle");
m.speakerIdx += 1;
const next = m.agentIds[m.speakerIdx % m.agentIds.length];
S.setStatus(next, "speaking");
S.setSpeech(next, pick(MEETING_PHRASES));
m.speakTimer = rand(2, 3.4);
}
}
if (m.timer <= 0 && m.gathered) {
for (const id of m.agentIds) {
S.setSpeech(id, null);
const brain = this.brains.get(id);
if (brain) {
brain.phase = "arriving"; // reuse: wait until seated home, then idle
brain.timer = 0;
}
}
S.addEvent("🏁", "會議結束,各自回工位");
S.endMeeting(m.id);
this.meeting = null;
this.meetingCooldown = rand(30, 55);
}
return;
}
this.meetingCooldown -= dt;
if (this.meetingCooldown > 0) return;
// candidates: settled main agents that are idle at their desk
const candidates = [...S.agents.values()].filter((a) => {
const brain = this.brains.get(a.id);
return (
a.role !== "subagent" &&
brain?.phase === "idle" &&
!a.movement &&
a.zone === "desk"
);
});
if (candidates.length < 2) {
this.meetingCooldown = 6;
return;
}
const count = Math.min(candidates.length, 2 + Math.floor(Math.random() * 2));
const chosen = candidates.slice(0, count).map((a) => a.id);
const id = `meeting-${meetingSeq++}`;
for (const agentId of chosen) {
const brain = this.brains.get(agentId)!;
brain.phase = "meeting";
}
S.addEvent("🤝", `${chosen.length} 位 agent 前往會議區討論`);
S.startMeeting(id, chosen);
this.meeting = { id, agentIds: chosen, timer: rand(12, 18), speakTimer: 0, speakerIdx: -1, gathered: false };
}
}