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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ new version heading in the same commit.

## [Unreleased]

## [0.459.0] - 2026-10-12
### Changed
- **Picking an assignee on an Inbox proposal card accepts it.** The card's assignee picker now moves an
agent-proposed task to `todo` with that person or agent assigned, the same as assigning it on the board
(one edit, so the assignee gets one notice; audited `task.proposal.accepted` with `via: 'assign'`).
Clearing the pick still leaves it proposed.
**For users:** Choosing who should work an agent's proposed task in the Inbox now puts it on the board for them — no separate Accept click. [Open Tasks](#/tasks)

## [0.458.0] - 2026-10-12
### Added
- **DataForSEO stops "disconnecting": a local, API-key connector, and the gate now notices when any remote connector loses its login.** Every tenant's DataForSEO connector pointed at the hosted `mcp.dataforseo.com` URL with no headers, which authorizes with an OAuth grant kept in Claude Code's own config dir. The grant expires, isn't there at all on a pooled runtime account, and when it's missing Claude Code silently swaps every tool for `authenticate`/`complete_authentication` stubs an unattended run can't complete (20 stub calls in one tenant's transcripts). initech's `composio-company` has failed the same way since 2026-09-08, taking GSC/GA4/DataForSEO/Stripe/Sheets with it, and nobody was told. Two catalog entries, **DataForSEO** (`dataforseo-mcp-server@3.1.3`: `api_request` + docs tools) and **DataForSEO (v2 tools)** (`@2.9.13`: the 89 per-endpoint tools existing prompts name), run the official server over stdio with `secret:DATAFORSEO_LOGIN`/`secret:DATAFORSEO_PASSWORD`. Each call is plain Basic auth, with nothing to expire. Versions are pinned because v2→v3 renamed every tool. A cold `npx` install measured 21–35s, close to the MCP startup timeout, so enabled `npx` connectors are pre-fetched at boot and on add/enable (`src/connectors/npx-warm.ts`, audited `connector.warmed`). And a call to any connector's OAuth stub now reaches the gate as what it is: audited `connector.auth.lost`, one admin card per connector per 6h naming the fix, and on an unattended run a refusal telling the agent to carry on without it instead of hunting for a login (`src/connectors/mcp-auth-stub.ts`). Runbook `docs/connectors/dataforseo.md`; pinned by `scripts/connector-auth-stub-test.cjs`.
Expand Down
9 changes: 5 additions & 4 deletions docs/tasks-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,10 +809,11 @@ status puts a human's accept between "an agent thought this is work" and "it is
nobody is told "assigned to you" for work no human agreed to, and the agent's `task_update({ assignee })`
on its proposal only revises the pick. Accepting with no assignee set adopts it (`TaskStore.update`, so
the Inbox Accept, Accept & run, and the board status picker all agree); dismissing doesn't.
- **Assigning IS accepting on the board.** A board `PATCH` that sets an assignee on a proposal (no status
in the same edit) moves it to `todo` — picking who works it is the reviewer saying it is work. Same
gate as the accept button, audited `task.proposal.accepted` with `via: 'assign'`. The Inbox card's
re-point (`action: 'assign'`) still leaves it proposed: there Accept / Accept & run sit right beside it.
- **Assigning IS accepting.** A board `PATCH` that sets an assignee on a proposal (no status in the same
edit) moves it to `todo` — picking who works it is the reviewer saying it is work — and so does the
Inbox card's re-point (`action: 'assign'` with an assignee). Same gate as the accept button, audited
`task.proposal.accepted` with `via: 'assign'`. Only clearing the pick (`assignee: null`) leaves it
proposed. To run it straight away, Accept & run still re-points and dispatches in one click.
- **Not held:** an auto-dispatch hand-off (a `task_wait` caller would hang on a click — bound those with
a delegation budget instead), a `goal:`-provenance run (a human asked for that plan), a human's task.
- **Bounds:** 25 open proposals per agent (`MAX_OPEN_TASK_PROPOSALS`), then `task_create` refuses. The
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.458.0",
"version": "0.459.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
17 changes: 11 additions & 6 deletions scripts/task-proposals-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,17 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js'));
assert(bad.status === 400, 'an unknown assignee is refused', bad);
const byBob = await member(bob, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: bob.id });
assert(byBob.status === 403 && !aos.tasks.get(p1.id).assignee, 'a bystander cannot re-point it', byBob);
const toBob = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: bob.id });
const t1 = aos.tasks.get(p1.id);
assert(toBob.status === 200 && t1.assignee === bob.id && t1.status === 'proposed', 'assign changes the assignee and leaves it proposed', t1);
assert(taskCards(p1.id).length === 0, 'and the new human assignee hears nothing until it is accepted', taskCards(p1.id).length);
const cleared = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'assign', assignee: null });
assert(cleared.status === 200 && !aos.tasks.get(p1.id).assignee, 'null clears it', aos.tasks.get(p1.id).assignee);
assert(cleared.status === 200 && !aos.tasks.get(p1.id).assignee && aos.tasks.get(p1.id).status === 'proposed', 'null clears the pick and leaves it proposed', aos.tasks.get(p1.id));
assert(taskCards(p1.id).length === 0, 'and nobody hears about work no one accepted', taskCards(p1.id).length);

const p0 = await create('ses_r', { title: 'assign from the card', suggestedAssignee: 'agent:qa' });
const toBob = await member(alice, 'POST', '/api/tasks/proposals/decide', { ids: [p0.id], action: 'assign', assignee: bob.id });
const t0 = aos.tasks.get(p0.id);
assert(toBob.status === 200 && toBob.body.decided.includes(p0.id) && t0.assignee === bob.id && t0.status === 'todo', 'picking an assignee on the card accepts it onto the board', t0);
assert(taskCards(p0.id).length === 1, 'and the new assignee hears about it exactly once', taskCards(p0.id).length);
const viaCard = aos.db.prepare("SELECT data FROM audit_events WHERE type = 'task.proposal.accepted' ORDER BY rowid DESC LIMIT 1").get();
assert(JSON.parse(viaCard.data).via === 'assign' && JSON.parse(viaCard.data).id === p0.id, 'audited as an accept via assign', viaCard.data);

const noAgent = await member(owner, 'POST', '/api/tasks/proposals/decide', { ids: [p1.id], action: 'accept', run: true });
assert(noAgent.body.error && aos.tasks.get(p1.id).status === 'proposed', 'Accept & run without an agent assignee accepts nothing', noAgent.body);
Expand All @@ -206,7 +211,7 @@ const { createHttpServer } = require(path.join(ROOT, 'dist/server.js'));
assert(go.status === 200 && go.body.decided.includes(p1.id) && aos.tasks.get(p1.id).status === 'todo', 'Accept & run accepts it', go.body);
assert(dispatched.includes(p1.id) && go.body.dispatched.length === 1, 'and dispatches it in the same click', go.body);
const rev = aos.db.prepare("SELECT count(*) n FROM audit_events WHERE type = 'task.proposal.reassigned'").get().n;
assert(rev === 3, 'each re-assignment is audited', rev);
assert(rev === 2, 'each re-assignment is audited (clearing an already-empty pick is not one)', rev);
}

console.log('\n\x1b[1m9) a proposal SUGGESTS who should work it; assigning on the board accepts it\x1b[0m');
Expand Down
13 changes: 11 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4395,8 +4395,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
? (tm.taskProposalCardTasks(b.messageId) ?? [])
: Array.isArray(b.ids) ? b.ids.map(String) : [];
if (!ids.length) return sendJson(res, 400, { error: 'no tasks named' });
// The reviewer can re-point a proposal at the right person/agent from the card itself — alone
// (`assign`, the task stays proposed) or in the same click as accepting it. `null` clears it.
// The reviewer can re-point a proposal at the right person/agent from the card itself. Picking someone
// (`assign`) IS accepting it — the same rule as assigning on the board: choosing who works it is the
// reviewer saying it is work. Only `null` (clearing the pick) leaves it proposed.
const hasAssignee = 'assignee' in b;
const assignee = hasAssignee ? (typeof b.assignee === 'string' && b.assignee ? b.assignee : null) : undefined;
if (action === 'assign' && !hasAssignee) return sendJson(res, 400, { error: 'assign needs an assignee (or null to clear it)' });
Expand All @@ -4414,6 +4415,14 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
const t = os.tasks.get(id);
if (!t || t.status !== 'proposed') continue;
if (!mayDecideProposal(me, t)) { denied++; continue; }
if (action === 'assign' && assignee) {
// One edit, so the new assignee gets one "assigned" notice for accepted work, not two.
if (!os.tasks.update(id, { assignee, status: 'todo', note: 'accepted onto the board', by: me.id })) continue;
if ((t.assignee ?? null) !== assignee) os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'task.proposal.reassigned', data: { id, from: t.assignee ?? null, to: assignee } });
os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'task.proposal.accepted', data: { id, title: t.title, by: t.createdBy, via: 'assign' } });
decided.push(id);
continue;
}
if (hasAssignee && (t.assignee ?? null) !== assignee) {
os.tasks.update(id, { assignee, by: me.id });
os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'task.proposal.reassigned', data: { id, from: t.assignee ?? null, to: assignee } });
Expand Down
3 changes: 2 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6939,7 +6939,7 @@ function ProposedTaskItem({ t, members, agents, busy, onDecide }: { t: ProposedT
{due && <span className={`inline-flex items-center gap-0.5 rounded px-1 ${due.tone}`}><Clock className="h-3 w-3" />{due.label}</span>}
{pending ? (
<Select value={pick || 'none'} onValueChange={(v) => { const next = !v || v === 'none' ? null : v; if (next !== (t.assignee ?? null)) onDecide('assign', [t.id], { assignee: next }) }}>
<SelectTrigger size="sm" className="h-6 max-w-52 gap-1 border-dashed px-1.5 text-[11px]" disabled={busy} aria-label="Assignee" title={suggested ? 'Suggested by the agent — accepting assigns it, or pick someone else' : undefined}>
<SelectTrigger size="sm" className="h-6 max-w-52 gap-1 border-dashed px-1.5 text-[11px]" disabled={busy} aria-label="Assignee" title={suggested ? 'Suggested by the agent — Accept assigns it, or pick someone else to accept it with them' : 'Picking someone accepts it onto the board'}>
<SelectValue>{(v) => '→ ' + (!v || v === 'none' ? 'Unassigned' : principalLabel(v as string, members)) + (suggested && v === pick ? ' (suggested)' : '')}</SelectValue>
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -7017,6 +7017,7 @@ function ActionItem({ m, me, members, agents, onOpen, onDismiss }: { m: Msg; me:
setBusy(false)
if (r.error) setHint('⚠ ' + r.error)
else if (extra?.run && r.dispatched?.some((d) => d.sessionId)) setHint('accepted — a session is running it')
else if (action === 'assign' && extra?.assignee && r.decided?.length) setHint('assigned and accepted onto the board')
}
return (
<div className="rounded-lg border border-violet-300 bg-violet-50/40 px-3 py-2.5">
Expand Down
Loading