diff --git a/README.md b/README.md index 2e96dbd..2aea61d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,22 @@ session transcript(JSONL),只讀取新增的位元組,轉譯成事件推播給 注意:監看伺服器會讀取本機的 Claude Code 對話記錄(含程式碼與提示詞), **只綁 127.0.0.1、不要對外開放**;如需團隊共用請自行加上驗證。 +### 在辦公室下任務(LIVE 模式) + +點選任一專案 agent,側邊面板會出現任務輸入框 —「🚀 派發任務」會透過 +`POST /task` 讓監看伺服器在該專案目錄 spawn 一個 headless session: + +``` +claude -p --permission-mode acceptEdits # prompt 由 stdin 傳入 +``` + +- 勾「續上次 session」= `--resume <最新 sessionId>`,延續該專案上一段對話 +- 執行過程會即時演在辦公室裡(思考 → 工具氣泡 → 回覆),結束時事件紀錄顯示 🏁 +- 同專案同時只跑一個任務,其餘排隊(上限 3);單一任務 15 分鐘逾時 +- 權限模式可用環境變數 `OFFICE_PERMISSION_MODE` 調整(acceptEdits / plan / bypassPermissions) +- 安全:`/task` 僅接受 localhost 來源 + Origin 白名單(5173/5180), + headless 執行等於允許 AI 改檔案跑指令 — **請理解風險後再用** + ## PM2 部署 ```bash diff --git a/server/watch.mjs b/server/watch.mjs index f330800..9f1f8fa 100644 --- a/server/watch.mjs +++ b/server/watch.mjs @@ -9,6 +9,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"; @@ -16,6 +17,18 @@ const PORT = Number(process.env.PORT || 5179); 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:5180", + "http://127.0.0.1:5180", +]); +const TASK_TIMEOUT_MS = 15 * 60_000; +const MAX_QUEUE = 3; +/** Headless permission mode — change to taste (acceptEdits / plan / bypassPermissions). */ +const PERMISSION_MODE = process.env.OFFICE_PERMISSION_MODE || "acceptEdits"; + const PROJECTS_ROOT = path.join(homedir(), ".claude", "projects"); /** slug → { slug, name, cwd, lastActivity } */ @@ -182,10 +195,12 @@ async function pollOnce(initial) { let tail = tails.get(full); if (!tail) { - // First sighting: skip history, only follow new appends. - tail = { offset: stat.size, remainder: "", slug }; + // 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: "", slug }; tails.set(full, tail); - continue; + if (!bornNow) continue; } if (stat.size < tail.offset) { tail.offset = stat.size; // truncated / rotated @@ -220,6 +235,154 @@ async function pollOnce(initial) { } } +/* ── task dispatch: turn the watcher into a tiny gateway ── */ + +/** slug → { child, startedAt, queue: [{prompt, resume}] } */ +const taskRuns = new Map(); + +/** Newest transcript's basename = session id, for --resume. */ +async function newestSessionId(slug) { + try { + const dir = path.join(PROJECTS_ROOT, slug); + const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".jsonl")); + let best = null; + let bestM = 0; + for (const f of files) { + const st = await fs.stat(path.join(dir, f)); + if (st.mtimeMs > bestM) { + bestM = st.mtimeMs; + best = f; + } + } + return best ? best.replace(/\.jsonl$/, "") : null; + } catch { + return null; + } +} + +async function launchTask(slug, prompt, resume) { + const proj = projects.get(slug); + if (!proj?.cwd) return; + + const args = ["-p", "--permission-mode", PERMISSION_MODE]; + if (resume) { + const sessionId = await newestSessionId(slug); + if (sessionId && /^[0-9a-fA-F-]+$/.test(sessionId)) args.push("--resume", sessionId); + } + + // Prompt goes through stdin — never through shell arguments. + const child = spawn("claude", 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; + } + await 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", @@ -236,6 +399,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 5947a90..6417be3 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 ( +