From 1ac46782a1302feafc1573e4f365d17f8b50a145 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Mon, 20 Jul 2026 14:21:44 +0800 Subject: [PATCH] =?UTF-8?q?LIVE=20=E6=A8=A1=E5=BC=8F=E5=8F=AF=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=9C=A8=E8=BE=A6=E5=85=AC=E5=AE=A4=E5=B0=8D=E5=B0=88?= =?UTF-8?q?=E6=A1=88=E6=B4=BE=E7=99=BC=E4=BB=BB=E5=8B=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: 與 claude-office 同功能:點選專案 agent 輸入任務,監看伺服器在該專案 cwd spawn headless session(codex exec --full-auto),過程即時可視。 根本原因: 實況模式原本只能唯讀觀察 rollout,無法從辦公室驅動真實 Codex 執行。 影響: watcher 新增 POST /task(Origin 白名單 5173/5182、每專案排隊上限 3、 15 分鐘逾時);--full-auto 允許 AI 在專案內改檔案,僅限 localhost 使用。 修法: - server/watch.mjs:launchTask 用 codex exec --full-auto --skip-git-repo-check - (prompt 走 stdin),resume 由該專案最新 rollout 檔名取 sessionId; 新生 rollout 檔從頭 tail(born-now) - live.ts:dispatchTask() + task_accepted/task_done;SidePanel TaskComposer Co-Authored-By: Claude Fable 5 --- README.md | 15 ++++ server/watch.mjs | 168 ++++++++++++++++++++++++++++++++++- src/components/SidePanel.tsx | 50 +++++++++++ src/gateway/live.ts | 42 ++++++++- src/styles.css | 49 ++++++++++ 5 files changed, 320 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 844f24c..2223a08 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,21 @@ npm run watch # http://localhost:5181/events (SSE) 注意:監看伺服器會讀取本機的 Codex 對話記錄,**只綁 127.0.0.1、不要對外開放**。 +### 在辦公室下任務(LIVE 模式) + +點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過 +`POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session: + +``` +codex exec --full-auto --skip-git-repo-check - # prompt 由 stdin 傳入 +``` + +- 勾「續上次 session」= `codex exec resume <該專案最新 sessionId>` +- 執行過程即時演在辦公室裡,結束時事件紀錄顯示 🏁(失敗會顯示 ⚠️ 與 stderr 摘要) +- 同專案同時只跑一個任務,其餘排隊(上限 3);單一任務 15 分鐘逾時 +- 安全:`/task` 僅接受 localhost 來源 + Origin 白名單(5173/5182), + `--full-auto` 允許 AI 在該專案內改檔案跑指令 — **請理解風險後再用** + ## PM2 部署 ```bash diff --git a/server/watch.mjs b/server/watch.mjs index f604b4a..8303af4 100644 --- a/server/watch.mjs +++ b/server/watch.mjs @@ -12,6 +12,7 @@ */ import { createServer } from "node:http"; import { createReadStream, promises as fs } from "node:fs"; +import { spawn } from "node:child_process"; import { homedir } from "node:os"; import path from "node:path"; @@ -19,6 +20,16 @@ const PORT = Number(process.env.PORT || 5181); const POLL_MS = 1500; const ACTIVE_WINDOW_DAYS = 7; +/** Origins allowed to dispatch tasks (browser CSRF guard; server is localhost-only). */ +const ALLOWED_ORIGINS = new Set([ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:5182", + "http://127.0.0.1:5182", +]); +const TASK_TIMEOUT_MS = 15 * 60_000; +const MAX_QUEUE = 3; + const SESSIONS_ROOT = path.join(homedir(), ".codex", "sessions"); /** slug → { slug, name, cwd, lastActivity } */ @@ -175,8 +186,10 @@ async function pollOnce(initial) { let tail = tails.get(full); if (!tail) { - // First sighting: skip history, only follow new appends. - tail = { offset: stat.size, remainder: "", cwd: null, slug: null }; + // Initial scan: skip history. Files born while we're running + // (e.g. a freshly dispatched task session) are read from the top. + const bornNow = !initial && Date.now() - stat.mtimeMs < 120_000; + tail = { offset: bornNow ? 0 : stat.size, remainder: "", cwd: null, slug: null, file: full, mtime: stat.mtimeMs }; tails.set(full, tail); const cwd = await sniffCwd(full, stat.size); if (cwd) { @@ -187,8 +200,9 @@ async function pollOnce(initial) { if (initial) proj.lastActivity = stat.mtimeMs; } } - continue; + if (!bornNow) continue; } + tail.mtime = stat.mtimeMs; if (stat.size < tail.offset) { tail.offset = stat.size; @@ -207,6 +221,150 @@ async function pollOnce(initial) { } } +/* ── task dispatch: turn the watcher into a tiny gateway ── */ + +/** slug → { child, startedAt, queue: [{prompt, resume}] } */ +const taskRuns = new Map(); + +/** Newest rollout for this project → session id (uuid in the filename), for resume. */ +function newestSessionId(slug) { + let best = null; + let bestM = 0; + for (const tail of tails.values()) { + if (tail.slug === slug && tail.mtime > bestM) { + bestM = tail.mtime; + best = tail.file; + } + } + if (!best) return null; + const m = path.basename(best).match(/([0-9a-fA-F]{8}-[0-9a-fA-F-]{27,})\.jsonl$/); + return m ? m[1] : null; +} + +function launchTask(slug, prompt, resume) { + const proj = projects.get(slug); + if (!proj?.cwd) return; + + const args = ["exec"]; + if (resume) { + const sessionId = newestSessionId(slug); + if (sessionId) args.push("resume", sessionId); + } + args.push("--full-auto", "--skip-git-repo-check", "-"); + + // Prompt goes through stdin ("-") — never through shell arguments. + const child = spawn("codex", args, { + cwd: proj.cwd, + shell: true, + stdio: ["pipe", "ignore", "pipe"], + windowsHide: true, + }); + const run = { child, startedAt: Date.now(), queue: taskRuns.get(slug)?.queue ?? [] }; + taskRuns.set(slug, run); + + let stderrTail = ""; + child.stderr.on("data", (c) => { + stderrTail = (stderrTail + c.toString("utf8")).slice(-400); + }); + child.stdin.write(prompt); + child.stdin.end(); + + const killer = setTimeout(() => child.kill(), TASK_TIMEOUT_MS); + broadcast({ type: "task_accepted", slug, resume: Boolean(resume) }); + + child.on("close", (code) => { + clearTimeout(killer); + broadcast({ + type: "task_done", + slug, + code, + error: code === 0 ? undefined : stderrTail.trim().slice(-200) || undefined, + }); + const next = run.queue.shift(); + if (next) { + launchTask(slug, next.prompt, next.resume); + } else { + taskRuns.delete(slug); + } + }); + child.on("error", (err) => { + clearTimeout(killer); + taskRuns.delete(slug); + broadcast({ type: "task_done", slug, code: -1, error: String(err).slice(0, 200) }); + }); +} + +function corsFor(req) { + const origin = req.headers.origin; + if (origin && ALLOWED_ORIGINS.has(origin)) { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Headers": "content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + Vary: "Origin", + }; + } + return {}; +} + +function readBody(req, limit = 100_000) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on("data", (c) => { + size += c.length; + if (size > limit) { + reject(new Error("too large")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +async function handleTaskRequest(req, res) { + const cors = corsFor(req); + if (req.method === "OPTIONS") { + res.writeHead(Object.keys(cors).length ? 204 : 403, cors); + res.end(); + return; + } + if (!Object.keys(cors).length) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "origin not allowed" })); + return; + } + const fail = (status, error) => { + res.writeHead(status, { ...cors, "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error })); + }; + try { + const body = JSON.parse(await readBody(req)); + const slug = String(body.slug ?? ""); + const prompt = String(body.prompt ?? "").trim(); + const resume = Boolean(body.resume); + if (!projects.has(slug)) return fail(404, "unknown project"); + if (!prompt || prompt.length > 8000) return fail(400, "prompt must be 1–8000 chars"); + + const running = taskRuns.get(slug); + if (running) { + if (running.queue.length >= MAX_QUEUE) return fail(429, "queue full"); + running.queue.push({ prompt, resume }); + res.writeHead(202, { ...cors, "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, queued: running.queue.length })); + return; + } + launchTask(slug, prompt, resume); + res.writeHead(200, { ...cors, "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, queued: 0 })); + } catch { + fail(400, "bad request"); + } +} + function snapshot() { return { type: "snapshot", @@ -218,6 +376,10 @@ function snapshot() { } const server = createServer((req, res) => { + if (req.url === "/task") { + handleTaskRequest(req, res); + return; + } if (req.url === "/events") { res.writeHead(200, { "Content-Type": "text/event-stream", diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx index e7308c1..878f385 100644 --- a/src/components/SidePanel.tsx +++ b/src/components/SidePanel.tsx @@ -1,11 +1,59 @@ +import { useState } from "react"; import { STATUS_COLORS, STATUS_LABELS } from "@/lib/constants"; import { generateAppearance } from "@/lib/appearance"; import { useOfficeStore } from "@/store/office-store"; import { getDirector } from "@/sim/runtime"; +import { dispatchTask } from "@/gateway/live"; import { Pawn } from "./Pawn"; const ROLE_LABELS = { lead: "Lead Agent", agent: "Agent", subagent: "Subagent" } as const; +/** LIVE mode: dispatch a real headless task to this project via the watcher. */ +function TaskComposer({ agentId }: { agentId: string }) { + const [prompt, setPrompt] = useState(""); + const [resume, setResume] = useState(false); + const [sending, setSending] = useState(false); + + const send = async () => { + const text = prompt.trim(); + if (!text || sending) return; + setSending(true); + const result = await dispatchTask(agentId, text, resume); + setSending(false); + if (result.ok) { + setPrompt(""); + if (result.queued) { + useOfficeStore.getState().addEvent("⏳", `任務已排隊(第 ${result.queued} 位)`); + } + } else { + useOfficeStore.getState().addEvent("⚠️", `派發失敗:${result.error ?? "未知錯誤"}`); + } + }; + + return ( +
+