diff --git a/packages/bus/README.md b/packages/bus/README.md
index 0cbbc8192..b0c469263 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -157,6 +157,7 @@ an operator inspect content and delivery evidence after an uncertain result.
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
+| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
### Agent state (row 59, #1548)
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index 732c7310e..ddc18f21c 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -654,7 +654,7 @@ export class Broker {
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
return this.#store.transaction(() => {
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages'].includes(request.verb))
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result);
@@ -1086,6 +1086,23 @@ export class Broker {
this.#agent(s);
return this.#requirements;
}
+ // Row 109 (#1603): what a run needs to know before it plans. The business's launch state with
+ // the time and id of the event that set it (`allowed`, no time and no id when none has), and
+ // the open tasks as the tracker snapshot shows them: refs and titles, at most `cap` of them,
+ // `total` counting every open task. A read for role, reader and human sessions; nothing is stored.
+ case 'state': {
+ keys(a, []);
+ if (!s.human && !s.reader) this.#agent(s);
+ const set = this.#store.get(
+ "SELECT id,kind,at FROM events WHERE business=? AND kind IN ('launch.revoked','launch.restored') ORDER BY seq DESC LIMIT 1",
+ s.business,
+ );
+ const open = this.#tasks(s.business, { done: false });
+ return {
+ launch: { state: set?.kind === 'launch.revoked' ? 'revoked' : 'allowed', at: set?.at ?? null, event: set?.id ?? null },
+ tasks: { open: open.tasks.map(({ task_ref, title }) => ({ task_ref, title })), total: open.total, cap: TASK_PAGE },
+ };
+ }
case 'inbox':
case 'agents':
case 'tasks':
diff --git a/packages/bus/tests/state.test.mjs b/packages/bus/tests/state.test.mjs
new file mode 100644
index 000000000..7ae0085a7
--- /dev/null
+++ b/packages/bus/tests/state.test.mjs
@@ -0,0 +1,113 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Store } from '../src/store.mjs';
+import { Broker, TASK_PAGE } from '../src/broker.mjs';
+// Row 109 (#1603): `state` is what a run reads before it plans: the launch state with the time and
+// id of the event that set it, and the open tasks from the tracker snapshot, capped.
+const authority = { withinRole: ['message.send', 'role.launch'], crossRole: [] };
+const roles = { pm: { authority }, coder: { authority } };
+const businesses = {
+ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, launch: { by: 'pm', instances: ['coder'], max: { opus: 2, sonnet: 2 } }, roles },
+};
+const at = '2026-10-12T12:00:00.000Z';
+const digest = 'd'.repeat(64);
+const ref = (i) => `vikunja:3/${i}`;
+const fields = (i, done = false) => ({ project_id: 3, title: `Task ${i}`, description: `
${i}
`, done, bucket: 11 });
+
+function setup(t) {
+ const root = mkdtempSync(join(tmpdir(), 'bus-state-'));
+ const store = new Store(root);
+ const b = new Broker({ store, businesses });
+ t.after(() => {
+ store.close();
+ rmSync(root, { recursive: true, force: true });
+ });
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
+ const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' });
+ b.request(pm, { verb: 'role.claim' });
+ const snap = (i, over = {}, read_at = at) => ({ task_ref: ref(i), updated: read_at, digest, fields: { ...fields(i), ...over }, via: 'board', read_at });
+ const record = (...snapshots) => b.recordTask({ business: 'demo', snapshots });
+ const state = (cap = pm, args = {}) => b.request(cap, { verb: 'state', args });
+ return { b, store, human, pm, snap, record, state, reader: b.bindReader({ business: 'demo' }) };
+}
+
+test('a business that never switched launches reads allowed with no time and no event', (t) => {
+ const { state } = setup(t);
+ assert.deepEqual(state().launch, { state: 'allowed', at: null, event: null });
+});
+
+test('launches revoked and then restored read allowed, with the restore time and event id', (t) => {
+ const { b, store, human, pm, state } = setup(t);
+ const revoked = b.request(human, { verb: 'launch.revoke' }).id;
+ assert.deepEqual(
+ { state: state().launch.state, event: state().launch.event },
+ { state: 'revoked', event: revoked },
+ );
+ const restored = b.request(human, { verb: 'launch.restore' }).id;
+ const row = store.get('SELECT at FROM events WHERE id=?', restored);
+ assert.deepEqual(state(pm).launch, { state: 'allowed', at: row.at, event: restored });
+ assert.notEqual(restored, revoked);
+});
+
+test('a task closed on the tracker leaves the open list, and total counts the open ones', (t) => {
+ const { snap, record, state } = setup(t);
+ record(snap(1), snap(2), snap(3));
+ assert.deepEqual(state().tasks, {
+ open: [1, 2, 3].map((i) => ({ task_ref: ref(i), title: `Task ${i}` })),
+ total: 3,
+ cap: TASK_PAGE,
+ });
+ record(snap(2, { done: true }, '2026-10-12T13:00:00.000Z'));
+ const { open, total } = state().tasks;
+ assert.deepEqual(open.map((x) => x.task_ref), [ref(1), ref(3)]);
+ assert.equal(total, 2);
+});
+
+test('a tombstone is not open', (t) => {
+ const { snap, record, state } = setup(t);
+ record(snap(1), { ...snap(2), fields: { gone: 'no-access' }, via: 'task' });
+ assert.deepEqual(state().tasks.open.map((x) => x.task_ref), [ref(1)]);
+});
+
+test('the open list stops at the cap and says so; total still counts every open task', (t) => {
+ const { snap, record, state } = setup(t);
+ record(...Array.from({ length: TASK_PAGE + 5 }, (_, k) => snap(k + 1)));
+ const { open, total, cap } = state().tasks;
+ assert.equal(open.length, TASK_PAGE);
+ assert.equal(total, TASK_PAGE + 5);
+ assert.equal(cap, TASK_PAGE);
+});
+
+test('a role and a reader session read state; arguments, and an unbound cap, refuse', (t) => {
+ const { reader, pm, state } = setup(t);
+ assert.deepEqual(state(reader), state(pm));
+ assert.throws(() => state(pm, { business: 'other' }), { code: 'invalid-request' });
+ assert.throws(() => state('f'.repeat(64)), /unauthenticated/);
+});
+
+test('a human session reads the same state, with the same cap', (t) => {
+ const { snap, record, human, pm, state } = setup(t);
+ record(...Array.from({ length: TASK_PAGE + 2 }, (_, k) => snap(k + 1)));
+ const read = state(human);
+ assert.deepEqual(read, state(pm));
+ assert.equal(read.tasks.cap, TASK_PAGE);
+ assert.equal(read.tasks.open.length, TASK_PAGE);
+ assert.equal(read.tasks.total, TASK_PAGE + 2);
+});
+
+test('reading state writes nothing: every table keeps its row count', (t) => {
+ const { b, store, human, pm, reader, snap, record, state } = setup(t);
+ b.request(human, { verb: 'launch.revoke' });
+ record(snap(1), snap(2));
+ const counts = () => Object.fromEntries(
+ store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
+ .map(({ name }) => [name, store.get(`SELECT COUNT(*) AS n FROM "${name}"`).n]),
+ );
+ const before = counts();
+ assert.ok(before.events > 0 && before.task_snapshots > 0);
+ for (const cap of [pm, reader, human]) state(cap);
+ assert.deepEqual(counts(), before);
+});
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 7e2623728..80d945dc2 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -234,10 +234,14 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
}
// The coder claimed its role and answers its own messages.
+ const before = Date.now();
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "hello" });
const echo = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id));
assert.equal(echo.from_role, "coder");
- assert.match(echo.body, /^echo: Message .* from cto, class REQUEST:\n\nhello/);
+ // Row 109 (#1603): the turn opens with the state block, then the header with the time it was sent.
+ const turn = echo.body.match(new RegExp(`^echo: Current state, read at \\S+:\nLaunches: allowed \\(never switched\\)\\.\nOpen tasks: 0\\.\n\nMessage ${hello.id} from cto, sent (\\S+), class REQUEST:\n\nhello$`));
+ assert.ok(turn, echo.body);
+ assert.ok(Date.parse(turn[1]) >= before - 1000 && Date.parse(turn[1]) <= Date.now(), "the sent time is when it was sent");
// Host refusals, asked with the cto capability (steps 2 to 8 come before
// the broker's role.launch check, which cto would fail).
diff --git a/packages/harness/README.md b/packages/harness/README.md
index 6f87ac731..4d2d6fb96 100644
--- a/packages/harness/README.md
+++ b/packages/harness/README.md
@@ -182,6 +182,15 @@ it never touches the disk and isn't in the environment or argv.
Its stdout goes back to the sender as a RESULT; a failed turn replies
`The turn failed: `; an incoming RESULT never gets an automatic
reply. Then `message.read`.
+
+ Row 109 (#1603): before each turn the runner calls the broker's `state`
+ verb and puts a block at the top of the request, headed `Current state,
+ read at