Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added after-conference-room.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added after-stage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added before-conference-room.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added before-stage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<HiveTask, 'id'>>) };
});

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)' };
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
Expand Down Expand Up @@ -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<MemoryStatus> => ipcRenderer.invoke('hive:memoryStatus'),
Expand Down Expand Up @@ -1080,6 +1094,9 @@ const api = {
id: string,
patch: Partial<Omit<HiveTask, 'id'>>
): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('hive:patchTask', id, patch),
/** Append a work-switch handoff note under hive/agents/<id>/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),
Expand Down
155 changes: 155 additions & 0 deletions src/renderer/src/components/AskMeStrip.tsx
Original file line number Diff line number Diff line change
@@ -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<HiveTask[]>([]);
const [sending, setSending] = useState<string | null>(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 (
<div style={{
flexShrink: 0,
borderTop: '1px solid var(--cth-ink-700)',
background: 'var(--cth-paper-200)',
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
gap: 6,
maxHeight: 220
}}>
<div style={{
fontFamily: 'var(--cth-font-ui)',
fontSize: 11,
fontWeight: 700,
color: 'var(--cth-ink-900)'
}}>{t('askMe.inboxTitle', { count: waiting.length })}</div>
<div style={{ overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0 }}>
{waiting.map((task) => {
const open = openQuestion(task);
if (!open) return null;
const draft = drafts[task.id] ?? '';
const busy = sending === task.id;
return (
<div key={task.id} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<button
type="button"
onClick={() => openTaskDetail(task.id)}
style={{
alignSelf: 'flex-start',
border: 'none',
background: 'transparent',
padding: 0,
cursor: 'pointer',
fontFamily: 'var(--cth-font-mono)',
fontSize: 11,
color: 'var(--cth-ink-900)',
textAlign: 'start'
}}
>{task.title || task.id}</button>
<div style={{
fontFamily: 'var(--cth-font-mono)',
fontSize: 12,
lineHeight: 1.35,
color: 'var(--cth-ink-700)',
whiteSpace: 'pre-wrap'
}}>{open.q}</div>
<textarea
value={draft}
aria-label={t('askMe.answerPlaceholder')}
placeholder={t('askMe.answerPlaceholder')}
onChange={(e) => setAnswerDraft(task.id, e.target.value)}
onKeyDown={(e) => {
if (isComposingKey(e)) return;
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void send(task);
}
}}
rows={2}
style={{
resize: 'vertical',
width: '100%',
boxSizing: 'border-box',
fontFamily: 'var(--cth-font-mono)',
fontSize: 12,
color: 'var(--cth-ink-900)',
background: 'var(--cth-paper-100)',
border: '1px solid var(--cth-ink-300)',
padding: '4px 6px'
}}
/>
<div style={{ display: 'flex', gap: 6 }}>
<PixelButton size="sm" disabled={busy || !draft.trim()} onClick={() => { void send(task); }}>
{busy ? t('askMe.sending') : t('askMe.respond')}
</PixelButton>
<PixelButton size="sm" variant="ghost" disabled={busy} title={t('askMe.dismissTitle')} onClick={() => { void dismiss(task); }}>
{t('askMe.dismissAria')}
</PixelButton>
</div>
</div>
);
})}
</div>
</div>
);
}
14 changes: 12 additions & 2 deletions src/renderer/src/components/CommandCenterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { PtyTerminalView } from './PtyTerminalView';
import { MessageQueueComposer } from './MessageQueueComposer';
import { TasksKanban } from './TasksKanban';
import { AskMeTab } from './AskMeTab';
import { ConferenceRoomTab } from './ConferenceRoomTab';
import { StageTab } from './StageTab';
import { TriggersTab } from './triggers/TriggersTab';
import { TriggerHistoryTab } from './triggers/TriggerHistoryTab';
import { WorkersTab } from './WorkersTab';
Expand Down Expand Up @@ -46,7 +48,7 @@ import { useRtl } from '@/i18n/useDirection';
// Both the AskMe (#human) tab and the Triggers tab live here. Triggers replaced
// the old Schedules tab: schedules are now one of four trigger types, and the
// whole surface lives in ./triggers (see src/shared/triggers.ts for the contract).
type CCTab = 'terminal' | 'floor' | 'tasks' | 'human' | 'triggers' | 'trigger-history'
type CCTab = 'terminal' | 'floor' | 'tasks' | 'human' | 'conference' | 'stage' | 'triggers' | 'trigger-history'
| 'memory' | 'graph' | 'activity' | 'skills' | 'workers';

/** Fallback denominator for the per-agent token meter when no floor token budget
Expand All @@ -70,6 +72,8 @@ const TABS: { key: CCTab; labelKey: string; icon: Parameters<typeof Icon>[0]['na
{ key: 'floor', labelKey: 'commandCenter.tabs.floor', icon: 'mcp' },
{ key: 'tasks', labelKey: 'commandCenter.tabs.tasks', icon: 'check' },
{ key: 'human', labelKey: 'commandCenter.tabs.human', icon: 'bell' },
{ key: 'conference', labelKey: 'commandCenter.tabs.conference', icon: 'mcp' },
{ key: 'stage', labelKey: 'commandCenter.tabs.stage', icon: 'sparkle' },
{ key: 'triggers', labelKey: 'commandCenter.tabs.triggers', icon: 'clock' },
{ key: 'trigger-history', labelKey: 'commandCenter.tabs.history', icon: 'ledger' },
{ key: 'memory', labelKey: 'commandCenter.tabs.memory', icon: 'sparkle' },
Expand Down Expand Up @@ -322,6 +326,8 @@ export function CommandCenterPanel({ agent, fullscreen = false }: { agent: Agent
{tab === 'floor' && <FloorTab seed={dispatchSeed} />}
{tab === 'tasks' && <TasksKanban />}
{tab === 'human' && <AskMeTab />}
{tab === 'conference' && <ConferenceRoomTab />}
{tab === 'stage' && <StageTab />}
{tab === 'triggers' && <TriggersTab />}
{tab === 'trigger-history' && <TriggerHistoryTab />}
{tab === 'memory' && (
Expand Down Expand Up @@ -563,9 +569,13 @@ function FloorTab({ seed }: { seed: { text: string; seq: number } }) {
const body = dispatchText.trim();
if (!body) return;
const suggested = dispatchTo ? agents.find((a) => a.id === dispatchTo) : undefined;
const full = suggested
let full = suggested
? `${body}\n\n${t('commandCenter.dispatchSuggestion', { name: suggested.name, id: suggested.id })}`
: body;
try {
const cfg = await window.cth.getConfig();
if (cfg.schruteFarmsMode) full = `${full}\n\n${t('stage.schruteBrief')}`;
} catch { /* noop */ }
const res = await window.cth.hiveSend(
{ to: 'god', act: 'request', subject: t('commandCenter.taskFromHuman'), body: full },
'human'
Expand Down
Loading
Loading