diff --git a/CHANGELOG.md b/CHANGELOG.md
index 876f2561..b8016ce7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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`.
diff --git a/docs/tasks-plan.md b/docs/tasks-plan.md
index a0168fcc..d4913dbb 100644
--- a/docs/tasks-plan.md
+++ b/docs/tasks-plan.md
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 41463640..d43843a3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agent-os",
- "version": "0.458.0",
+ "version": "0.459.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-os",
- "version": "0.458.0",
+ "version": "0.459.0",
"license": "MIT",
"bin": {
"agent-os": "bin/agent-os"
diff --git a/package.json b/package.json
index deedbf85..207ba7f1 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/task-proposals-test.cjs b/scripts/task-proposals-test.cjs
index 68b81095..1f5a6300 100644
--- a/scripts/task-proposals-test.cjs
+++ b/scripts/task-proposals-test.cjs
@@ -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);
@@ -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');
diff --git a/src/server.ts b/src/server.ts
index 4f1286d5..92e3f4ef 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -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)' });
@@ -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 } });
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 52d9775a..d6d5a90d 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -6939,7 +6939,7 @@ function ProposedTaskItem({ t, members, agents, busy, onDecide }: { t: ProposedT
{due && {due.label}}
{pending ? (