建立 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>
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user