Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ Every PR that bumps `package.json` moves its entries from **Unreleased** into a
new version heading in the same commit.

## [Unreleased]

## [0.282.0] — 2026-07-31
### Added
- **Cockpit `action` tier now *executes* — a governed operator carries it out (auto-router Phase 3).**
A detected action ("create a task to migrate the acme site", "run the churn report every morning") no
longer just deep-links — clicking **Set it up** spawns the **operator**: an ephemeral System agent
(`src/edge/concierge.ts`, sibling to the read-only concierge), run-as the member, that carries out the
request via the **governed** tools and nothing else — `task_create` (filed immediately, audited) for a
task, `automation_propose` (a DRAFT an owner must approve — it never fires unattended) for a scheduled
job. Cockpit polls its transcript and shows the one-line confirmation inline ("✓ Created task: …" /
"✓ Proposed automation … — pending an owner's approval"), with a link to Tasks / the Inbox. Explicit
consent by design: nothing executes until **Set it up** is clicked; the operator can't bypass the gate
hook, and an automation still needs a human approval — so this adds no ungoverned power. The card keeps
"Open Automations/Tasks" (do it yourself) and "Route to an agent" as alternatives. New endpoint
`POST /api/router/act`; the operator is `category:'System'` so it's never a route target.
`src/edge/concierge.ts` (`ensureOperator`/`OPERATOR_ID` + shared provisioner), `src/server.ts`,
`web/src/App.tsx`, `web/src/lib/api.ts`.
### Fixed
- **Governance conformance passes off the author's Mac again.** Three `file-guard` fixtures hardcoded
`/Users/vmini/.ssh/…` as "the service user's home", but `sensitiveWriteRoots` resolves the home with
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.281.0",
"version": "0.282.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
91 changes: 69 additions & 22 deletions src/edge/concierge.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
/**
* The **concierge** — a code-provisioned System agent that answers freeform questions ABOUT this
* workspace for Cockpit's `ask` tier. It exists so `ask` can answer questions that need reasoning/tools
* WITHOUT depending on a separately-configured LLM API key: the native "LLM" in Agent OS is a claude
* session (authenticated via the agents' subscription), and every session already gets the OS MCP tools
* (recall / kb_search / session_history / task_list / list_capabilities / directory_lookup). So the
* concierge answers by querying real state, not a hand-assembled context snapshot.
* Cockpit's two code-provisioned System agents — the native-LLM backends for the `ask` and `action`
* tiers, so neither needs a separately-configured LLM API key (the native "LLM" in Agent OS is a claude
* session). Both run as ordinary one-shot chat runs (governed, run-as the member); Cockpit polls the
* transcript and renders the result inline. Category `System` keeps them out of the agent ROUTER (you're
* never "routed to the concierge" for work) and the fleet picker. Provisioning mirrors the consolidator:
* idempotent, writes a manifest + persona under the home's agents dir, registers a live claude runtime.
*
* It's spawned as an ordinary one-shot chat run (governed, run-as the asker), and Cockpit renders its
* reply inline. Category `System` keeps it out of the agent ROUTER (you never get "routed to the
* concierge" for work) and out of the fleet picker. Provisioning mirrors the consolidator: idempotent,
* writes a manifest + persona under the home's agents dir, registers a live claude-code runtime.
* - **concierge** (read-only) — answers questions ABOUT the workspace using its tools.
* - **operator** (acts) — carries out a requested action via the GOVERNED tools: `task_create`
* (auto-applied) and `automation_propose` (a draft an owner approves). It cannot bypass governance —
* every tool call still passes the gate hook, and an automation never fires until a human approves.
*/
import * as fs from 'fs';
import * as path from 'path';
import { AgentManifest } from '../types';
import { AgentOS } from '../kernel';

export const CONCIERGE_ID = 'concierge';
export const OPERATOR_ID = 'operator';

const MANIFEST: AgentManifest = {
/** Provision + register a System agent from its manifest + persona. Idempotent; safe to call on demand. */
function ensureSystemAgent(os: AgentOS, manifest: AgentManifest, claudeMd: string): void {
if (os.agents.get(manifest.id)?.dir) return;
const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents');
const dir = path.join(base, manifest.id);
fs.mkdirSync(dir, { recursive: true });
const manifestPath = path.join(dir, 'agent.json');
if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), claudeMd);
os.registerAgent({ ...manifest, dir });
}

const CONCIERGE_MANIFEST: AgentManifest = {
id: CONCIERGE_ID,
version: '1.0.0',
description: 'Workspace concierge — answers questions about this Agent OS workspace using its own tools.',
Expand All @@ -29,7 +42,7 @@ const MANIFEST: AgentManifest = {
budget: { usdCap: 0.5, tokenCap: 150_000, wallClockMs: 300_000 },
};

const CLAUDE_MD = `# Workspace concierge
const CONCIERGE_MD = `# Workspace concierge

You are the **concierge** for this Agent OS workspace. A member asked a question about the workspace
itself — the agents, sessions, tasks, automations, memory, knowledge base, policy, or how any of it
Expand All @@ -49,15 +62,49 @@ works. Answer it, concisely, grounded in **real state**.

You act on the member's behalf and only read workspace state — nothing you do needs their connectors.`;

/** Ensure the concierge agent exists on disk + is registered as a live runtime. Idempotent; safe to call
* on every `ask` that needs it. Mirrors the consolidator's self-provisioning. */
const OPERATOR_MANIFEST: AgentManifest = {
id: OPERATOR_ID,
version: '1.0.0',
description: 'Workspace operator — carries out a requested action (create a task / propose an automation) via governed tools.',
category: 'System',
principal: 'svc-operator',
policyContext: 'default@v3',
runtime: 'claude-code',
budget: { usdCap: 0.5, tokenCap: 150_000, wallClockMs: 300_000 },
};

const OPERATOR_MD = `# Workspace operator

A member asked you to **set something up** in this Agent OS workspace. Do exactly that — nothing more —
using the governed tools, then confirm in one line what happened. You act on the member's behalf.

## What to do
- **A task / to-do** ("create a task to migrate the acme site", "add a todo to call the vendor") →
call \`task_create\` with a short imperative \`title\` (and \`body\`/\`assignee\` only if the member
gave them). It's filed immediately. Confirm: "✓ Created task: <title>".
- **A recurring / scheduled / triggered job** ("run the churn report every morning", "audit the fleet
weekly") → call \`automation_propose\`: a short \`name\`, the \`task\` (the prompt the run executes each
time), a 5-field cron \`schedule\` (e.g. "0 9 * * 1-5" = 9am weekdays), and \`agentId\` = the best-fit
agent for the job (use \`directory_lookup\` / \`list_capabilities\` to pick; don't guess a name — verify
it exists). Include a one-line \`rationale\`. This is a **DRAFT an owner must approve** — it will NOT
fire until then. Confirm: "✓ Proposed automation "<name>" (<when>, <agent>) — pending an owner's approval
in the Inbox".

## Rules
- **Do ONLY what was asked.** One task, or one automation proposal. Never invent extra work, and never
take a destructive or privilege-bearing action beyond these two tools.
- **Make sensible defaults and state them** (e.g. "every morning" → "0 9 * * *"). If something essential
is genuinely ambiguous (which agent should run it, and you can't tell), pick the closest fit and say so
in your confirmation rather than stalling.
- Your reply text IS the confirmation shown inline — no preamble, no sign-off, lead with the "✓" line.
Do not call \`report\`/\`ask\`.`;

/** Ensure the read-only concierge exists. */
export function ensureConcierge(os: AgentOS): void {
if (os.agents.get(CONCIERGE_ID)?.dir) return;
const base = os.paths?.userAgents ?? path.join(process.cwd(), 'data', 'agents');
const dir = path.join(base, CONCIERGE_ID);
fs.mkdirSync(dir, { recursive: true });
const manifestPath = path.join(dir, 'agent.json');
if (!fs.existsSync(manifestPath)) fs.writeFileSync(manifestPath, JSON.stringify(MANIFEST, null, 2));
if (!fs.existsSync(path.join(dir, 'CLAUDE.md'))) fs.writeFileSync(path.join(dir, 'CLAUDE.md'), CLAUDE_MD);
os.registerAgent({ ...MANIFEST, dir });
ensureSystemAgent(os, CONCIERGE_MANIFEST, CONCIERGE_MD);
}

/** Ensure the action-taking operator exists. */
export function ensureOperator(os: AgentOS): void {
ensureSystemAgent(os, OPERATOR_MANIFEST, OPERATOR_MD);
}
18 changes: 17 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle
import { chooseAgent } from './edge/router';
import { classifyIntent } from './edge/intent';
import { resolveLlm, chatComplete } from './edge/llm';
import { ensureConcierge, CONCIERGE_ID } from './edge/concierge';
import { ensureConcierge, CONCIERGE_ID, ensureOperator, OPERATOR_ID } from './edge/concierge';
import { answerFromState } from './edge/ask';
import { SlackSocket } from './edge/slack-socket';
import { ClickupIngress } from './edge/clickup-ingress';
Expand Down Expand Up @@ -2610,6 +2610,22 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:

return respondWork();
}

// Cockpit `action` execution (Phase 3): the member explicitly clicked "Set it up" on a detected action
// ("create a task to…", "schedule … every morning"). Spawn the governed **operator** — an ephemeral
// System agent, run-as the member — which carries it out via `task_create` (filed) or
// `automation_propose` (a draft an owner approves; nothing fires unattended). Every tool call still
// passes the gate hook; this endpoint only starts the run — the client polls its transcript for the
// one-line confirmation. Explicit-consent by design: nothing executes until this is called.
if (method === 'POST' && p === '/api/router/act') {
const b = await readBody(req);
const text = String(b.text || '').trim();
if (!text) return sendJson(res, 400, { error: 'text is required' });
ensureOperator(os);
if (!os.agents.get(OPERATOR_ID)?.dir) return sendJson(res, 500, { error: 'could not start the operator' });
const s = tm.createSession(OPERATOR_ID, chatTitle(text, OPERATOR_ID), text, `chat:${me.id}`, true, undefined, undefined, me.id, undefined, false);
return sendJson(res, 200, { sessionId: s.id });
}
// Read the friendly conversation timeline for a session (poll this like the rest of the console).
const convoMatch = p.match(/^\/api\/sessions\/([\w-]+)\/conversation$/);
if (method === 'GET' && convoMatch) {
Expand Down
71 changes: 48 additions & 23 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3911,11 +3911,12 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
const runPreview = async (force?: 'work') => {
const text = draft.trim()
if (!text || busy) return
setBusy(true); setErr(''); setPreview(null)
setBusy(true); setErr(''); setPreview(null); setRunId('')
const r = await api.routerPreview(text, force)
setBusy(false)
if (r.error) { setErr(r.error); return }
setPreview(r)
if (r.intent === 'ask' && r.run?.sessionId) setRunId(r.run.sessionId) // concierge answer streams in
}

// Dispatch the chosen agent in the selected launch mode, using the ORIGINAL message as its task.
Expand All @@ -3940,34 +3941,46 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runPreview() }
}

// `ask` band 2b: the server started an ephemeral concierge run — poll its transcript and surface the
// reply inline (so a claude-backed answer still feels session-free). Stops once the reply stabilises.
const runSession = preview?.intent === 'ask' ? preview.run?.sessionId : undefined
const [askAnswer, setAskAnswer] = useState('')
const [askThinking, setAskThinking] = useState(false)
// An ephemeral System-agent run whose reply we surface inline — the `ask` concierge (band 2b) OR the
// `action` operator (Phase 3, started by "Set it up"). Either way it feels session-free: we poll the
// transcript and render the one-line result, stopping once it stabilises.
const [runId, setRunId] = useState('')
const [runAnswer, setRunAnswer] = useState('')
const [runThinking, setRunThinking] = useState(false)
const prevAnswer = useRef('')
useEffect(() => {
setAskAnswer(''); prevAnswer.current = ''
if (!runSession) { setAskThinking(false); return }
setAskThinking(true)
setRunAnswer(''); prevAnswer.current = ''
if (!runId) { setRunThinking(false); return }
setRunThinking(true)
let stop = false
const poll = async () => {
const r = await api.conversation(runSession)
const r = await api.conversation(runId)
if (stop) return
const assistant = (r.turns || []).filter((t) => t.kind !== 'user')
const last = assistant[assistant.length - 1]
const text = (last && 'text' in last ? last.text : '') || ''
if (text) {
setAskAnswer(text)
if (text === prevAnswer.current) { setAskThinking(false); stop = true; clearInterval(timer); clearTimeout(to) } // stable → done
setRunAnswer(text)
if (text === prevAnswer.current) { setRunThinking(false); stop = true; clearInterval(timer); clearTimeout(to) } // stable → done
prevAnswer.current = text
}
}
poll()
const timer = setInterval(poll, 1500)
const to = setTimeout(() => { stop = true; clearInterval(timer); setAskThinking(false) }, 60000)
const to = setTimeout(() => { stop = true; clearInterval(timer); setRunThinking(false) }, 90000)
return () => { stop = true; clearInterval(timer); clearTimeout(to) }
}, [runSession])
}, [runId])

// Phase 3: carry out a detected action via the governed operator run.
const setUp = async () => {
const text = draft.trim()
if (!text || busy) return
setBusy(true); setErr('')
const r = await api.routerAct(text)
setBusy(false)
if (r.error || !r.sessionId) { setErr(r.error || 'could not start the operator'); return }
setRunId(r.sessionId)
}

const isWork = !preview?.intent || preview.intent === 'work'

Expand Down Expand Up @@ -4031,31 +4044,43 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
<div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{preview.answer}</ReactMarkdown></div>
)}
{preview.run && (
askAnswer
? <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{askAnswer}</ReactMarkdown></div>
runAnswer
? <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{runAnswer}</ReactMarkdown></div>
: <div className="flex items-center gap-2 py-2 text-sm text-muted-foreground"><RefreshCw className="h-3.5 w-3.5 animate-spin" />Looking into it…</div>
)}
<div className="mt-3 flex items-center gap-3 text-xs">
<button onClick={() => runPreview('work')} className="text-muted-foreground underline-offset-2 hover:underline">Not what you meant? Route to an agent instead →</button>
{preview.run && !askThinking && askAnswer && <button onClick={() => onOpenChat(preview.run!.sessionId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button>}
{preview.run && !runThinking && runAnswer && <button onClick={() => onOpenChat(preview.run!.sessionId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button>}
</div>
</div>
)}

{/* ACTION — deep-link into the primitive's surface; execution stays human-driven. */}
{/* ACTION — let the governed operator carry it out ("Set it up"), or open the surface / route. */}
{preview.intent === 'action' && preview.surface && (
<div className="rounded-xl border bg-card p-4">
<div className="flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10">{preview.surface === 'automations' ? <Zap className="h-4.5 w-4.5 text-primary" /> : <ListChecks className="h-4.5 w-4.5 text-primary" />}</span>
<div className="min-w-0 flex-1">
<div className="font-medium">This looks like {preview.surface === 'automations' ? 'scheduling an automation' : 'creating a task'}.</div>
<div className="mt-0.5 text-xs text-muted-foreground">Open the {preview.surface === 'automations' ? 'Automations' : 'Tasks'} page to set it up — or have an agent do it for you.</div>
<div className="mt-0.5 text-xs text-muted-foreground">{preview.surface === 'automations' ? 'I can draft the automation for an owner to approve' : "I'll create the task for you"} — or open the {preview.surface === 'automations' ? 'Automations' : 'Tasks'} page yourself.</div>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={() => nav(preview.surface === 'automations' ? 'automations' : 'tasks')}>Open {preview.surface === 'automations' ? 'Automations' : 'Tasks'}</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => runPreview('work')}>Have an agent do it</Button>
</div>
{runId ? (
runAnswer
? <div className="mt-3 rounded-lg border bg-muted/40 p-3"><div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1"><ReactMarkdown remarkPlugins={[remarkGfm]}>{runAnswer}</ReactMarkdown></div>
<div className="mt-2 flex gap-3 text-xs">
<button onClick={() => nav(preview.surface === 'automations' ? 'inbox' : 'tasks')} className="text-muted-foreground underline-offset-2 hover:underline">{preview.surface === 'automations' ? 'Review in Inbox →' : 'Open Tasks →'}</button>
<button onClick={() => onOpenChat(runId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button>
</div>
</div>
: <div className="mt-3 flex items-center gap-2 py-1 text-sm text-muted-foreground"><RefreshCw className="h-3.5 w-3.5 animate-spin" />Setting it up…</div>
) : (
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={busy} onClick={setUp}>Set it up</Button>
<Button size="sm" variant="outline" onClick={() => nav(preview.surface === 'automations' ? 'automations' : 'tasks')}>Open {preview.surface === 'automations' ? 'Automations' : 'Tasks'}</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => runPreview('work')}>Route to an agent</Button>
</div>
)}
</div>
)}

Expand Down
Loading
Loading