diff --git a/after-conference-room.png b/after-conference-room.png new file mode 100644 index 0000000000..e07b7fe8be Binary files /dev/null and b/after-conference-room.png differ diff --git a/after-stage.png b/after-stage.png new file mode 100644 index 0000000000..78de42ffbd Binary files /dev/null and b/after-stage.png differ diff --git a/before-conference-room.png b/before-conference-room.png new file mode 100644 index 0000000000..3636f8939c Binary files /dev/null and b/before-conference-room.png differ diff --git a/before-stage.png b/before-stage.png new file mode 100644 index 0000000000..3de9c5125e Binary files /dev/null and b/before-stage.png differ diff --git a/src/main/config.ts b/src/main/config.ts index f66c854c15..57afda9080 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -367,6 +367,9 @@ export interface HarnessConfig { * auto-disconnect on idle — the spend cap remains the runaway guard. The user * tunes this in Settings → Realtime Michael. */ realtimeIdleDisconnectMs?: number; + /** Stage → Schrute Farms Mode: when on, Conference briefs and floor→god + * dispatches append a threat-model + rollback requirement. Default off. */ + schruteFarmsMode?: boolean; // ─── Generic inbound webhook + status API (LEGACY, single-endpoint) ───────── // Superseded by `webhookTriggers`, which allows many endpoints over one server @@ -461,6 +464,7 @@ const DEFAULTS: HarnessConfig = { freeflowModel: 'whisper-large-v3-turbo', realtimeVoiceEnabled: false, realtimeIdleDisconnectMs: 180_000, + schruteFarmsMode: false, webhookEnabled: false, webhookSecret: undefined, webhookPort: undefined, diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d6..353e449a96 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3526,6 +3526,32 @@ ipcMain.handle('hive:patchTask', (_evt, id: unknown, patch: unknown) => { if (!hive.enabled()) return { ok: false, error: 'hive disabled (no harnessHome)' }; return { ok: hive.patchTask(id, patch as Partial>) }; }); + +ipcMain.handle('hive:writeHandoff', (_evt, agentId: unknown, markdown: unknown) => { + if (typeof agentId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(agentId)) { + return { ok: false, error: 'bad agent id' }; + } + if (typeof markdown !== 'string' || !markdown.trim() || markdown.length > 60_000) { + return { ok: false, error: 'bad handoff' }; + } + const root = hive.root(); + if (!root) return { ok: false, error: 'hive disabled (no harnessHome)' }; + try { + const dir = join(root, 'agents', agentId); + mkdirSync(dir, { recursive: true }); + const file = join(dir, 'handoff.md'); + let prior = ''; + try { prior = readFileSync(file, 'utf8'); } catch { /* first handoff */ } + const next = prior.trim() + ? `${markdown.trim()}\n\n---\n\n${prior.trim()}\n` + : `${markdown.trim()}\n`; + writeFileSync(file, next.slice(0, 80_000), 'utf8'); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +}); + ipcMain.handle('hive:deleteTask', (_evt, id: unknown) => { if (typeof id !== 'string' || !id) return { ok: false, error: 'invalid task id' }; if (!hive.enabled()) return { ok: false, error: 'hive disabled (no harnessHome)' }; @@ -5090,6 +5116,60 @@ ipcMain.handle('workers:list', (): { live: WorkerSnapshot[]; preserved: Preserve * exit arrives the exit-handler's stale-id guard already misreads it as a * reclaimed id and skips teardown — the worker would stay "live" in registry.json * and fleet.json forever after this call. */ + +/** Human-started temp worker. Files the same spawn-request JSON the watcher drains. */ +ipcMain.handle('workers:enqueue', (_evt, body: unknown) => { + const root = hive.root(); + const dir = spawnRequestsDir(); + if (!root || !dir) return { ok: false as const, error: 'hive disabled (no harnessHome)' }; + if (!body || typeof body !== 'object') return { ok: false as const, error: 'bad request' }; + const src = body as { + objective?: unknown; + cwd?: unknown; + command?: unknown; + provider?: unknown; + model?: unknown; + fromAgentId?: unknown; + fromAgentName?: unknown; + }; + const objective = typeof src.objective === 'string' ? src.objective.trim() : ''; + if (!objective || objective.length > 8000) return { ok: false as const, error: 'objective is required' }; + const cwd = typeof src.cwd === 'string' ? expandTilde(src.cwd.trim()) : ''; + if (!cwd) return { ok: false as const, error: 'cwd is required' }; + try { + if (!statSync(cwd).isDirectory()) return { ok: false as const, error: 'cwd is not a folder' }; + } catch { + return { ok: false as const, error: 'cwd not found' }; + } + const command = typeof src.command === 'string' ? src.command.replace(/[\r\n]/g, ' ').trim().slice(0, 500) : ''; + const model = typeof src.model === 'string' ? src.model.replace(/[\r\n]/g, '').trim().slice(0, 120) : ''; + const provider = typeof src.provider === 'string' ? src.provider.replace(/[\r\n]/g, '').trim().slice(0, 40) : ''; + const fromId = typeof src.fromAgentId === 'string' ? src.fromAgentId.replace(/[^\w.-]/g, '').slice(0, 80) : ''; + const fromName = typeof src.fromAgentName === 'string' + ? src.fromAgentName.replace(/[\r\n]/g, ' ').trim().slice(0, 80) + : ''; + const id = `h${randomBytes(4).toString('hex')}`; + const who = fromName || fromId || 'an agent'; + const request: SpawnRequest = { + id, + objective: `[Started by ${who}${fromId ? ` (${fromId})` : ''} for one job.]\n\n${objective}`, + cwd, + isolate: true, + name: `Temp · ${who}`.slice(0, 80) + }; + if (command) request.command = command; + if (model) request.model = model; + if (provider) request.provider = provider as AgentProvider; + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${id}.json`), JSON.stringify(request, null, 2), 'utf8'); + } catch (e) { + return { ok: false as const, error: e instanceof Error ? e.message : String(e) }; + } + const maxWorkers = Math.max(1, readConfig().maxConcurrentWorkers ?? 4); + return { ok: true as const, id, waiting: liveWorkers.size >= maxWorkers }; +}); + ipcMain.handle('workers:stop', (_evt, workerId: string): { ok: boolean; error?: string } => { if (typeof workerId !== 'string' || !workerId) return { ok: false, error: 'invalid worker id' }; const rec = liveWorkers.get(workerId); diff --git a/src/preload/index.ts b/src/preload/index.ts index ffdb5382a7..fec096d16f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -311,6 +311,8 @@ export interface HarnessConfig { /** Realtime voice idle auto-disconnect (ms); default 180000 (3 min), 0 = never. * Tuned in Settings → Realtime Michael; the cost cap stays the runaway guard. */ realtimeIdleDisconnectMs?: number; + /** Stage → Schrute Farms Mode (threat model + rollback on new work). Default off. */ + schruteFarmsMode?: boolean; costCapUsd?: number; costCapTokens?: number; agentTokenCaps?: Record; @@ -781,6 +783,18 @@ const api = { /** Manually stop a live ephemeral worker (safety-gated teardown; work preserved). */ stopWorker: (workerId: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('workers:stop', workerId), + /** File one human spawn-request. The watcher starts it, or leaves it queued + * when the worker cap is already full (`waiting`). */ + enqueueWorker: (body: { + objective: string; + cwd: string; + command?: string; + provider?: string; + model?: string; + fromAgentId?: string; + fromAgentName?: string; + }): Promise<{ ok: true; id: string; waiting: boolean } | { ok: false; error: string }> => + ipcRenderer.invoke('workers:enqueue', body), // ─── Semantic memory (MemPalace CLI) ───────────────────────────────────── memoryStatus: (): Promise => ipcRenderer.invoke('hive:memoryStatus'), @@ -1080,6 +1094,9 @@ const api = { id: string, patch: Partial> ): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('hive:patchTask', id, patch), + /** Append a work-switch handoff note under hive/agents//handoff.md. */ + writeHandoff: (agentId: string, markdown: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('hive:writeHandoff', agentId, markdown), /** Atomically remove one named card from the latest main-process ledger. */ hiveDeleteTask: (id: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('hive:deleteTask', id), diff --git a/src/renderer/src/components/AskMeStrip.tsx b/src/renderer/src/components/AskMeStrip.tsx new file mode 100644 index 0000000000..31fb808914 --- /dev/null +++ b/src/renderer/src/components/AskMeStrip.tsx @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PixelButton } from './PixelButton'; +import { useStore, type Agent } from '@/store/store'; +import { type HiveTask, openQuestion, waitsOnHuman } from './TasksKanban'; +import { compareByNewestAsk } from './askMeOrder'; +import { answerOpenQuestion, dismissOpenQuestion } from './askMeActions'; +import { isComposingKey } from '@shared/imeGuard'; + +const POLL_MS = 5000; + +function parse(raw: unknown): HiveTask[] { + const list = (raw && typeof raw === 'object' && Array.isArray((raw as { tasks?: unknown }).tasks)) + ? (raw as { tasks: HiveTask[] }).tasks + : []; + return list.filter((t) => !!t && typeof t === 'object'); +} + +/** + * The open human questions for this desk, sitting on the composer instead of + * only inside the Command Center Ask Me tab. Michael sees every unanswered + * card. Anyone else sees the cards assigned to them. + */ +export function AskMeStrip({ agent }: { agent: Agent }) { + const { t } = useTranslation(); + const drafts = useStore((s) => s.answerDrafts); + const setAnswerDraft = useStore((s) => s.setAnswerDraft); + const openTaskDetail = useStore((s) => s.openTaskDetail); + const [tasks, setTasks] = useState([]); + const [sending, setSending] = useState(null); + + const refresh = useCallback(async () => { + try { setTasks(parse(await window.cth.hiveTasks())); } catch { /* keep last good */ } + }, []); + + useEffect(() => { + void refresh(); + const timer = setInterval(() => { void refresh(); }, POLL_MS); + return () => clearInterval(timer); + }, [refresh]); + + const waiting = tasks + .filter(waitsOnHuman) + .filter((task) => agent.isGod || task.assignee === agent.id) + .sort((a, b) => compareByNewestAsk(openQuestion(a), openQuestion(b))); + + if (waiting.length === 0) return null; + + const send = async (task: HiveTask) => { + const text = (drafts[task.id] ?? '').trim(); + if (!text || sending) return; + setSending(task.id); + const result = await answerOpenQuestion(task.id, text); + if (result.ok) { + setAnswerDraft(task.id, ''); + await refresh(); + } + setSending(null); + }; + + const dismiss = async (task: HiveTask) => { + if (sending) return; + setSending(task.id); + const result = await dismissOpenQuestion(task.id); + if (result.ok) await refresh(); + setSending(null); + }; + + return ( +
+
{t('askMe.inboxTitle', { count: waiting.length })}
+
+ {waiting.map((task) => { + const open = openQuestion(task); + if (!open) return null; + const draft = drafts[task.id] ?? ''; + const busy = sending === task.id; + return ( +
+ +
{open.q}
+