@orboto/mcp 0.226.2 → 0.228.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.js +13 -2
  2. package/dist/manifest-size.test.js +3 -0
  3. package/dist/project-scope.js +15 -1
  4. package/dist/project-scope.test.js +9 -9
  5. package/dist/response-budget.js +1 -1
  6. package/dist/server.js +5 -0
  7. package/dist/session-origin.js +23 -3
  8. package/dist/tools/agent-coordination.js +101 -36
  9. package/dist/tools/agent-coordination.test.js +62 -22
  10. package/dist/tools/agent-instructions.js +5 -4
  11. package/dist/tools/agent-messages-compact.test.js +3 -3
  12. package/dist/tools/agent-messages.js +24 -4
  13. package/dist/tools/agent-presence-row.js +22 -0
  14. package/dist/tools/agent-who.js +18 -18
  15. package/dist/tools/agent-who.test.js +9 -2
  16. package/dist/tools/bulk-writes.js +3 -3
  17. package/dist/tools/checklist-writes.js +2 -2
  18. package/dist/tools/claim.js +2 -2
  19. package/dist/tools/doc-attachments.js +4 -4
  20. package/dist/tools/doc-comments.js +5 -5
  21. package/dist/tools/doc-export.js +3 -3
  22. package/dist/tools/doc-revisions.js +3 -3
  23. package/dist/tools/docs.js +7 -7
  24. package/dist/tools/get-timer.js +6 -1
  25. package/dist/tools/identity.js +10 -1
  26. package/dist/tools/identity.test.js +11 -0
  27. package/dist/tools/list-git-app-installations.js +1 -1
  28. package/dist/tools/phase-b-tools.test.js +7 -0
  29. package/dist/tools/presence-scope.test.js +170 -0
  30. package/dist/tools/primer-facts.js +15 -15
  31. package/dist/tools/project-readiness-schema.js +1 -1
  32. package/dist/tools/read-budget.test.js +161 -0
  33. package/dist/tools/responsibilities.test.js +1 -1
  34. package/dist/tools/rule-proposals.js +131 -0
  35. package/dist/tools/rule-proposals.test.js +82 -0
  36. package/dist/tools/semantic-degraded-wrapper.test.js +1 -1
  37. package/dist/tools/session-start.js +52 -16
  38. package/dist/tools/session-start.test.js +8 -7
  39. package/dist/tools/shared.js +49 -0
  40. package/dist/tools/ticket-writes.js +2 -2
  41. package/dist/tools/wiki.js +2 -2
  42. package/dist/tools/work-next.js +5 -5
  43. package/dist/tools/work-sessions.js +15 -15
  44. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -70,16 +70,27 @@ async function main() {
70
70
  const { mcpProcessInstance } = await import('./tools/shared.js');
71
71
  const instanceToken = mcpProcessInstance();
72
72
  const { declareEnvProjectScope } = await import('./project-scope.js');
73
- await declareEnvProjectScope(preflightClient, instanceToken);
73
+ const declared = await declareEnvProjectScope(preflightClient, instanceToken);
74
74
  const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
75
75
  inbox.start();
76
+ if (declared?.refusal)
77
+ void inbox.deliverNotice('session_exists', declared.refusal);
76
78
  const outdated = cliOutdatedNotice(process.env.ORBOTO_MCP_CLI_OUTDATED);
77
79
  if (outdated)
78
80
  void inbox.deliverNotice(CLI_OUTDATED_NOTICE, outdated);
79
81
  const { readClaudeContext, channelClientInfo } = await import('./claude-context.js');
82
+ const { presenceRefusal } = await import('./tools/shared.js');
83
+ let refusalLogged = false;
80
84
  const beat = () => {
81
85
  preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd()), undefined, channelStart ?? undefined) }, { instanceToken })
82
- .catch((err) => { console.error(`[orboto-mcp] channel heartbeat failed: ${err.message}`); });
86
+ .then(() => { refusalLogged = false; })
87
+ .catch((err) => {
88
+ const refused = presenceRefusal(err);
89
+ if (refused && refusalLogged)
90
+ return;
91
+ refusalLogged = Boolean(refused);
92
+ console.error(`[orboto-mcp] channel heartbeat ${refused ? 'refused' : 'failed'}: ${refused?.message ?? err.message}`);
93
+ });
83
94
  };
84
95
  beat();
85
96
  const beats = setInterval(beat, 60_000);
@@ -62,6 +62,9 @@ afterEach(() => { vi.restoreAllMocks(); });
62
62
  * ORB-2563 gave `orboto_agent_notify` `remind` and `urgent` inside the
63
63
  * ceiling: the docs `content` parameter description shrank to pay for it
64
64
  * (full = 171,194).
65
+ * ORB-2579 added the four rule proposal tools to the full tier after
66
+ * tightening repeated parameter descriptions (doc ids, bulk selectors,
67
+ * work-session leases, primer facts) by 3.0k (full = 169,607).
65
68
  *
66
69
  * @see ORB-1805, ORB-1669, ORB-1910
67
70
  */
@@ -43,7 +43,21 @@ export async function declareEnvProjectScope(client, instanceToken, raw = proces
43
43
  const current = await client.get('/v1/agent/session', { instanceToken }).catch(() => null);
44
44
  if (current && scopeDeclared(current.scope))
45
45
  return current;
46
- const row = await client.post('/v1/agent/heartbeat', { scope, scopeDefault: true }, { instanceToken }).catch(() => null);
46
+ const declaration = { scope, scopeDefault: true, start: true };
47
+ const row = await client.post('/v1/agent/heartbeat', declaration, { instanceToken })
48
+ .catch(async (err) => {
49
+ const { exitedPredecessor, presenceRefusal } = await import('./tools/shared.js');
50
+ const { machineId, shortHost } = await import('./session-origin.js');
51
+ const refused = presenceRefusal(err);
52
+ const dead = exitedPredecessor(refused, { machine: machineId(), host: shortHost() });
53
+ if (dead) {
54
+ const again = await client.post('/v1/agent/heartbeat', { ...declaration, previous: 'end' }, { instanceToken }).catch(() => null);
55
+ if (again)
56
+ console.error(`[orboto-mcp] ${dead.line}`);
57
+ return again;
58
+ }
59
+ return refused ? { refusal: refused.message } : null;
60
+ });
47
61
  if (row && typeof row.sessionId === 'string')
48
62
  rememberDeclaredScope(instanceToken, row.scope ?? scope);
49
63
  return row;
@@ -18,7 +18,7 @@ function fakeClient(row) {
18
18
  },
19
19
  };
20
20
  }
21
- beforeEach(() => { _forgetDeclaredScopes(); });
21
+ beforeEach(() => { _forgetDeclaredScopes(); vi.stubEnv(PROJECT_KEY_ENV, ''); vi.stubEnv(SESSION_ROLE_ENV, ''); });
22
22
  afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
23
23
  describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
24
24
  it('names the same variable the CLI writes into the entry and hands to the session', () => {
@@ -41,7 +41,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
41
41
  await declareEnvProjectScope(client, 'agent-abc', 'ACME');
42
42
  expect(client.calls).toEqual([
43
43
  { method: 'GET', path: '/v1/agent/session', instanceToken: 'agent-abc' },
44
- { method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }, instanceToken: 'agent-abc' },
44
+ { method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }, instanceToken: 'agent-abc' },
45
45
  ]);
46
46
  expect(rememberedScope('agent-abc')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
47
47
  });
@@ -58,7 +58,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
58
58
  expect(await declareEnvProjectScope(none, 'agent-none', '')).toBeNull();
59
59
  expect(none.calls).toEqual([]);
60
60
  });
61
- it('orboto_session_start without a scope declares the environment project on a row that has none', async () => {
61
+ it('orboto_session_start without a scope sends the environment project as fallbackScope and declares it itself on an instance that ignores it', async () => {
62
62
  vi.stubEnv(PROJECT_KEY_ENV, 'acme');
63
63
  const beats = [];
64
64
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
@@ -79,7 +79,7 @@ describe('ORB-2224 - ORBOTO_PROJECT_KEY', () => {
79
79
  });
80
80
  const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
81
81
  const res = await makeSessionStartHandler(client)({}, { sessionId: 'scope1' });
82
- expect(beats).toEqual([{}, { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }]);
82
+ expect(beats).toEqual([{ start: true, fallbackScope: { role: 'worker', projectKeys: ['ACME'] } }, { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }]);
83
83
  expect(res.content[0].text).toContain('projects ACME');
84
84
  expect(rememberedScope('mcp-scope1')).toEqual({ role: 'worker', projectKeys: ['ACME'] });
85
85
  });
@@ -99,10 +99,10 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
99
99
  expect(envProjectScope(undefined, 'lead')).toBeNull();
100
100
  const client = fakeClient({ sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null });
101
101
  await declareEnvProjectScope(client, 'agent-control', undefined, 'coordinator');
102
- expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' }, scopeDefault: true }, instanceToken: 'agent-control' });
102
+ expect(client.calls[1]).toEqual({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'coordinator' }, scopeDefault: true, start: true }, instanceToken: 'agent-control' });
103
103
  expect(rememberedScope('agent-control')).toEqual({ role: 'coordinator' });
104
104
  });
105
- it('orboto_session_start without a scope declares the control session the environment names', async () => {
105
+ it('orboto_session_start without a scope sends the control session the environment names as fallbackScope in its one start beat', async () => {
106
106
  vi.stubEnv(SESSION_ROLE_ENV, 'coordinator');
107
107
  vi.stubEnv(PROJECT_KEY_ENV, 'ACME');
108
108
  const beats = [];
@@ -112,7 +112,7 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
112
112
  if (u.pathname === '/v1/agent/heartbeat') {
113
113
  const sent = init?.body ? JSON.parse(init.body) : {};
114
114
  beats.push(sent);
115
- body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? null };
115
+ body = { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent.scope ?? sent.fallbackScope ?? null };
116
116
  }
117
117
  else if (u.pathname === '/v1/agent/session') {
118
118
  body = { sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: null };
@@ -124,7 +124,7 @@ describe('ORB-2448 - ORBOTO_SESSION_ROLE', () => {
124
124
  });
125
125
  const client = new OrbotoClient({ baseUrl: 'http://api.test', apiKey: 'k' });
126
126
  await makeSessionStartHandler(client)({}, { sessionId: 'control1' });
127
- expect(beats).toEqual([{}, { scope: { role: 'coordinator' }, scopeDefault: true }]);
127
+ expect(beats).toEqual([{ start: true, fallbackScope: { role: 'coordinator' } }]);
128
128
  expect(rememberedScope('mcp-control1')).toEqual({ role: 'coordinator' });
129
129
  });
130
130
  });
@@ -145,6 +145,6 @@ describe('ORB-2593 - a picker-pending orboto claude start', () => {
145
145
  writeFileSync(join(dir, 'state.json'), JSON.stringify({ pid: 4000 }));
146
146
  const resolved = fakeClient({ sessionId: 'x', scope: null });
147
147
  await declareEnvProjectScope(resolved, 'agent-abc', 'ACME', undefined, supervisorScopePending(env));
148
- expect(resolved.calls.filter((c) => c.method === 'POST')).toEqual([{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true }, instanceToken: 'agent-abc' }]);
148
+ expect(resolved.calls.filter((c) => c.method === 'POST')).toEqual([{ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'worker', projectKeys: ['ACME'] }, scopeDefault: true, start: true }, instanceToken: 'agent-abc' }]);
149
149
  });
150
150
  });
@@ -239,7 +239,7 @@ export const TruncationBlockSchema = z.object({
239
239
  */
240
240
  export const TruncationBlockAdvertisedSchema = z.object({ handle: z.string() })
241
241
  .passthrough()
242
- .describe('Response cut; pass `handle` to orboto_response_expand.');
242
+ .describe('Cut: pass handle to orboto_response_expand.');
243
243
  function stringMarker(omitted, handle, path) {
244
244
  return `... [truncated ${omitted} chars - full value: orboto_response_expand handle="${handle}" path="${path}"]`;
245
245
  }
package/dist/server.js CHANGED
@@ -24,6 +24,7 @@ import { helpToolConfig, makeHelpHandler } from './tools/help.js';
24
24
  import { apiSearchToolConfig, makeApiSearchHandler } from './tools/api-search.js';
25
25
  import { apiCallToolConfig, makeApiCallHandler } from './tools/api-call.js';
26
26
  import { listAgentInstructionsToolConfig, makeListAgentInstructionsHandler, createAgentInstructionToolConfig, makeCreateAgentInstructionHandler, updateAgentInstructionToolConfig, makeUpdateAgentInstructionHandler, resetAgentInstructionToolConfig, makeResetAgentInstructionHandler, deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler, } from './tools/agent-instructions.js';
27
+ import { ruleProposeToolConfig, makeRuleProposeHandler, ruleProposalsToolConfig, makeRuleProposalsHandler, ruleProposalAcceptToolConfig, makeRuleProposalAcceptHandler, ruleProposalRejectToolConfig, makeRuleProposalRejectHandler, } from './tools/rule-proposals.js';
27
28
  import { agentHeartbeatToolConfig, makeAgentHeartbeatHandler, agentPresenceToolConfig, makeAgentPresenceHandler, agentNotifyToolConfig, makeAgentNotifyHandler, } from './tools/agent-coordination.js';
28
29
  import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/agent-coordination.js';
29
30
  import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
@@ -200,6 +201,10 @@ export async function buildOrbotoMcpServer(opts) {
200
201
  reg('orboto_update_agent_instruction', updateAgentInstructionToolConfig, makeUpdateAgentInstructionHandler(client));
201
202
  reg('orboto_reset_agent_instruction', resetAgentInstructionToolConfig, makeResetAgentInstructionHandler(client));
202
203
  reg('orboto_delete_agent_instruction', deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler(client));
204
+ reg('orboto_rule_propose', ruleProposeToolConfig, makeRuleProposeHandler(client));
205
+ reg('orboto_rule_proposals', ruleProposalsToolConfig, makeRuleProposalsHandler(client));
206
+ reg('orboto_rule_proposal_accept', ruleProposalAcceptToolConfig, makeRuleProposalAcceptHandler(client));
207
+ reg('orboto_rule_proposal_reject', ruleProposalRejectToolConfig, makeRuleProposalRejectHandler(client));
203
208
  reg('orboto_agent_heartbeat', agentHeartbeatToolConfig, makeAgentHeartbeatHandler(client));
204
209
  reg('orboto_agent_presence', agentPresenceToolConfig, makeAgentPresenceHandler(client));
205
210
  reg('orboto_who', agentWhoToolConfig, makeAgentWhoHandler(client));
@@ -6,12 +6,12 @@
6
6
  */
7
7
  import { createHash } from 'node:crypto';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { existsSync, readFileSync } from 'node:fs';
9
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
10
10
  import { hostname } from 'node:os';
11
11
  import { basename, dirname, join } from 'node:path';
12
12
  import { VERSION } from './version.js';
13
13
  const KEYS = [
14
- ['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'], ['model', 'model'],
14
+ ['host', 'host'], ['machine', 'machineId'], ['repo', 'repo'], ['pid', 'pid'], ['start', 'startedAt'], ['client', 'client'], ['version', 'clientVersion'], ['harness', 'harness'], ['model', 'model'], ['checkout', 'checkout'],
15
15
  ];
16
16
  const WRAPPED_HARNESSES = { claude: 'claude-code', 'claude-code': 'claude-code', codex: 'codex', gemini: 'gemini', kimi: 'kimi', agy: 'agy', antigravity: 'agy' };
17
17
  /** ORB-2571 - the harness that started a stdio proxy: the one `orboto <harness>` named, else the markers a harness sets. */
@@ -55,6 +55,26 @@ export function repoBasename(cwd = process.cwd()) {
55
55
  }
56
56
  return basename(cwd) || null;
57
57
  }
58
+ /** ORB-2553 - the hashed working tree the directory sits in (a linked worktree is its own), the same value the CLI sends; never a path. */
59
+ export function checkoutHash(cwd = process.cwd()) {
60
+ let root = cwd;
61
+ for (let dir = cwd, i = 0; i < 40; i += 1) {
62
+ if (existsSync(join(dir, '.git'))) {
63
+ root = dir;
64
+ break;
65
+ }
66
+ const parent = dirname(dir);
67
+ if (parent === dir)
68
+ break;
69
+ dir = parent;
70
+ }
71
+ let physical = root;
72
+ try {
73
+ physical = realpathSync(root);
74
+ }
75
+ catch { /* an unreadable path hashes as given */ }
76
+ return createHash('sha256').update(physical).digest('hex').slice(0, 16);
77
+ }
58
78
  let machine;
59
79
  /** A hashed stable machine id (the platform UUID on macOS, /etc/machine-id elsewhere); null when none is readable. */
60
80
  export function machineId(read = readMachineId) {
@@ -87,7 +107,7 @@ export function stdioOrigin(env = process.env) {
87
107
  return params.toString();
88
108
  }
89
109
  return formatOrigin({
90
- host: shortHost(), machineId: machineId(), repo: repoBasename(), pid: process.ppid || null,
110
+ host: shortHost(), machineId: machineId(), repo: repoBasename(), checkout: checkoutHash(), pid: process.ppid || null,
91
111
  client: 'orboto-mcp', clientVersion: VERSION,
92
112
  harness: stdioHarness(env),
93
113
  model: /^[\x21-\x7e]{1,80}$/.test((env.ORBOTO_MODEL ?? '').trim()) ? env.ORBOTO_MODEL.trim() : null,
@@ -4,10 +4,11 @@
4
4
  * @see ORB-704, ORB-706
5
5
  */
6
6
  import { z } from 'zod';
7
- import { AGENT_WAKE_LABELS, AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
7
+ import { AGENT_WAKE_LABELS, AgentInventoryKindSchema, AgentSessionWakeSchema, } from './agent-presence-schema.js';
8
+ import { AgentPresenceRowSchema } from './agent-presence-row.js';
8
9
  import { OrbotoApiError } from '../orboto-client.js';
9
- import { mcpInstanceToken } from './shared.js';
10
- import { AgentSessionScopeSchema } from './agent-session-scope.js';
10
+ import { mcpInstanceToken, presenceRefusal } from './shared.js';
11
+ import { AgentSessionRoleSchema, AgentSessionScopeSchema } from './agent-session-scope.js';
11
12
  export const agentHeartbeatToolConfig = {
12
13
  title: 'Agent heartbeat (Multi-Agent Coordination)',
13
14
  description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it. `startedBy`: starter session id, same account.',
@@ -35,7 +36,16 @@ export const agentHeartbeatToolConfig = {
35
36
  };
36
37
  export function makeAgentHeartbeatHandler(client) {
37
38
  return async (args, extra) => {
38
- const res = await client.post('/v1/agent/heartbeat', args, { instanceToken: mcpInstanceToken(undefined, extra) });
39
+ let res;
40
+ try {
41
+ res = await client.post('/v1/agent/heartbeat', args, { instanceToken: mcpInstanceToken(undefined, extra) });
42
+ }
43
+ catch (err) {
44
+ const refused = presenceRefusal(err);
45
+ if (!refused)
46
+ throw err;
47
+ return { content: [{ type: 'text', text: `heartbeat refused (ORB-2553) - ${refused.message}` }], isError: true };
48
+ }
39
49
  const scope = res.scope ? ` scope=${JSON.stringify(res.scope)}` : '';
40
50
  const notes = (res.notes ?? []).map((n) => `\n${n}`).join('');
41
51
  return {
@@ -54,45 +64,99 @@ export function originText(s) {
54
64
  const where = [o.host ?? 'unknown host', o.repo, o.pid ? `pid ${o.pid}` : null, o.harness, o.client ? `${o.client}${o.clientVersion ? ` ${o.clientVersion}` : ''}` : null].filter(Boolean).join(' ');
55
65
  return ` [${s.state ?? 'live'}] on ${where}${o.startedAt ? ` since ${o.startedAt}` : ''}`;
56
66
  }
67
+ /** ORB-2548 - the page an MCP presence read returns by default, sized so four rows fit the 4,000-character budget. */
68
+ export const PRESENCE_PAGE_LIMIT = 4;
69
+ export const PresenceFilterShape = {
70
+ role: AgentSessionRoleSchema.optional(),
71
+ state: z.enum(['live', 'quiet', 'orphaned', 'unknown_origin']).optional(),
72
+ email: z.string().min(3).max(320).optional(),
73
+ sessionId: z.string().min(8).max(36).optional(),
74
+ kind: AgentInventoryKindSchema.optional(),
75
+ wake: AgentSessionWakeSchema.optional(),
76
+ limit: z.number().int().min(1).max(200).optional(),
77
+ cursor: z.string().max(500).optional(),
78
+ };
79
+ export function presenceQuery(args, extra = {}) {
80
+ const qs = new URLSearchParams(extra);
81
+ for (const key of ['project', 'role', 'state', 'email', 'sessionId', 'kind', 'wake', 'cursor', 'for']) {
82
+ const value = args[key]?.trim();
83
+ if (value)
84
+ qs.set(key, value);
85
+ }
86
+ qs.set('limit', String(args.limit ?? PRESENCE_PAGE_LIMIT));
87
+ return qs.toString();
88
+ }
89
+ /** ORB-2548 - the answer for a project filter the API does not know or the caller may not see (its 404). */
90
+ export function unknownProjectText(key) {
91
+ return `Project "${key.trim().toUpperCase()}" is unknown or not visible to you - no session is listed for it.`;
92
+ }
93
+ /** Runs the presence read; a 404 for a project filter becomes null, so the tool answers unknownProjectText instead of a raw error. */
94
+ export async function readPresence(client, path, project) {
95
+ try {
96
+ return await client.get(path);
97
+ }
98
+ catch (err) {
99
+ if (project?.trim() && err instanceof OrbotoApiError && err.status === 404)
100
+ return null;
101
+ throw err;
102
+ }
103
+ }
104
+ /** The line that ends a page with more rows behind it. */
105
+ export function nextPageLine(nextCursor) {
106
+ return nextCursor ? [`More sessions: call again with cursor "${nextCursor}", or narrow with email, sessionId, project, role, kind, wake or state.`] : [];
107
+ }
108
+ function presenceRow(s) {
109
+ const o = s.origin;
110
+ return {
111
+ sessionId: s.sessionId, userEmail: s.userEmail, userFullName: s.userFullName, kind: s.kind,
112
+ owner: s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : null),
113
+ status: s.status, lane: s.lane?.name ?? null, scope: s.scope ?? null, memberProjects: s.projects.length,
114
+ connections: [...new Set(s.connections.map((c) => c.type))], ...(s.wake ? { wake: s.wake } : {}), ...(s.state ? { state: s.state } : {}),
115
+ origin: o ? { host: o.host, repo: o.repo, pid: o.pid, harness: o.harness, client: o.client } : null,
116
+ workingOn: s.workingOnTicket ? s.workingOnTicket.key ?? s.workingOnTicket.id : null,
117
+ lastSeenAt: String(s.lastSeenAt), ...(s.redacted ? { redacted: true } : {}),
118
+ };
119
+ }
120
+ function presenceLine(s) {
121
+ const name = s.userFullName ?? s.userEmail;
122
+ const runtime = s.clientInfo.name ?? 'unknown';
123
+ const work = s.workingOnTicket ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})` : '';
124
+ const owner = s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self');
125
+ const declared = s.scope?.projectKeys ?? [];
126
+ const scope = s.scope?.role || declared.length ? `; scope: ${s.scope?.role ?? 'no role'}${declared.length ? ` on ${declared.join(', ')}` : ''}` : '';
127
+ const keys = s.projects.map((p) => p.key);
128
+ const projects = keys.length === 0 ? '' : keys.length <= 8 ? `; projects: ${keys.join(', ')}` : `; member of ${keys.length} projects`;
129
+ const connections = s.connections.length ? `; connections: ${[...new Set(s.connections.map((c) => c.type))].join(', ')}` : '';
130
+ return `- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${owner}${s.lane ? `; lane: ${s.lane.name}` : ''}${scope}${projects}${connections}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`;
131
+ }
57
132
  export const agentPresenceToolConfig = {
58
133
  title: 'Workspace agent presence',
59
- description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections; rows carry kind (agent | human by credential; alias isBot), actsAs, owner, projects, connections, wake (on_call: nothing wakes it), lane, sessionId, userEmail, runtime, capabilities, status, current ticket, reconnects, origin (host, repo, pid, client, harness, credential), state (live | quiet | orphaned | unknown_origin). Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those on their projects, a foreign row redacted (no pid, repo, machine, connections, inbox, owner). projectKey and kind filter client-side (`orboto agents --project KEY --bots|--humans`).',
134
+ description: 'Active agent instances. The orboto_who set from GET /v1/agent/who?include=inventory: lane workers and reviewers, inbox, MCP and runner connections. Each row: sessionId, account, kind (agent | human by credential), owner or acts-as, status, lane, declared scope, member project count, connection types, wake (on_call: nothing wakes it), state (live | quiet | orphaned | unknown_origin), origin (host, repo, pid, harness, client), current ticket. Filters run on the server: email, sessionId (id or 8+ character prefix), projectKey (sessions declared on it), role, kind, wake, state; 4 rows a page, nextCursor continues (cursor). A projectKey that is unknown or not visible to you answers so in one line, with no rows. Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those on their projects, a foreign row redacted. The full inventory row: `orboto agents --json`.',
60
135
  inputSchema: z.object({
61
- projectKey: z.string().min(1).max(64).optional().describe('Only rows whose projects hold this key.'),
62
- kind: AgentInventoryKindSchema.optional().describe('Only rows of this kind (by credential).'),
136
+ projectKey: z.string().min(1).max(64).optional(),
137
+ ...PresenceFilterShape,
63
138
  }).shape,
64
139
  outputSchema: z.object({
65
- sessions: z.array(AgentInventoryEntrySchema),
140
+ sessions: z.array(AgentPresenceRowSchema),
141
+ nextCursor: z.string().nullable(),
66
142
  }).shape,
67
143
  annotations: { readOnlyHint: true, idempotentHint: true },
68
144
  };
69
- /** Client-side view filter (ORB-2135); the inventory route stays unfiltered and the chat mirror applies the same rule. */
70
- export function filterPresenceRows(rows, args) {
71
- const key = args.projectKey?.trim().toUpperCase();
72
- return rows.filter((row) => (!args.kind || row.kind === args.kind) && (!key || row.projects.some((p) => p.key.toUpperCase() === key)));
73
- }
74
145
  export function makeAgentPresenceHandler(client) {
75
146
  return async (args = {}) => {
76
- const all = (await client.get('/v1/agent/who?include=inventory')).inventory ?? [];
77
- const sessions = filterPresenceRows(all, args);
78
- const lines = [];
79
- if (sessions.length === 0) {
80
- lines.push(all.length === 0 ? 'No active agent sessions in the workspace.' : `No active agent sessions match the filter (${all.length} active in the workspace).`);
81
- }
82
- else {
83
- lines.push(`${sessions.length} active session(s):`);
84
- for (const s of sessions) {
85
- const name = s.userFullName ?? s.userEmail;
86
- const runtime = s.clientInfo.name ?? 'unknown';
87
- const work = s.workingOnTicket
88
- ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
89
- : '';
90
- lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${originText(s)}${work}; owner: ${s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self')}${s.lane ? `; lane: ${s.lane.name}` : ''}${s.projects.length ? `; projects: ${s.projects.map((p) => p.key).join(', ')}` : ''}${s.connections.length ? `; connections: ${s.connections.map((c) => c.type).join(', ')}` : ''}${s.wake ? `; ${AGENT_WAKE_LABELS[s.wake]}` : ''}`);
91
- }
92
- }
147
+ const { projectKey, ...filters } = args;
148
+ const project = projectKey ?? filters.project;
149
+ const res = await readPresence(client, `/v1/agent/who?${presenceQuery({ ...filters, project }, { include: 'inventory' })}`, project);
150
+ if (!res)
151
+ return { content: [{ type: 'text', text: unknownProjectText(project) }], structuredContent: { sessions: [], nextCursor: null } };
152
+ const rows = res.inventory ?? [];
153
+ const filtered = Object.entries(args).some(([key, value]) => key !== 'limit' && key !== 'cursor' && value !== undefined && value !== '');
154
+ const lines = rows.length === 0
155
+ ? [filtered ? 'No active agent session matches the filter.' : 'No active agent sessions in the workspace.']
156
+ : [`${rows.length} active session(s)${res.nextCursor ? ' on this page' : ''}:`, ...rows.map(presenceLine), ...nextPageLine(res.nextCursor)];
93
157
  return {
94
158
  content: [{ type: 'text', text: lines.join('\n') }],
95
- structuredContent: { sessions },
159
+ structuredContent: { sessions: rows.map(presenceRow), nextCursor: res.nextCursor ?? null },
96
160
  };
97
161
  };
98
162
  }
@@ -121,7 +185,7 @@ export function noRecipientText(err) {
121
185
  return null;
122
186
  const p = body.errorParams ?? {};
123
187
  const live = (p.liveSessions ?? []).map((s) => `${s.addresses[0] ?? s.email} (${s.email}, ${s.shortId})`);
124
- const ways = 'a role address in `to` (integrator@KEY), `toSessionRef` with an instance short id, or `queue: true` with `project` to keep it until a session declares the project';
188
+ const ways = 'a role address in `to` (integrator@KEY), `toSessionRef` with an instance short id, or `queue: true` with `project` (a role address needs no project) to keep it until a session declares the project or the role';
125
189
  if (p.reason === 'ambiguous_account') {
126
190
  return `not sent: ${p.address ?? 'that account'} has ${live.length} live sessions you may address and a request needs one of them. Sessions: ${live.join('; ')}. Send again with \`toSessionRef\` set to the instance short id of one, or address a role (${ways}).`;
127
191
  }
@@ -163,7 +227,7 @@ export function reminderTooSoonText(err) {
163
227
  }
164
228
  export const agentNotifyToolConfig = {
165
229
  title: 'Notify an agent or user',
166
- description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). outcome: one sentence, 10 to 1000 characters; body: the plain text of the message (any length, lands in payload.body; payload may carry other JSON); threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it. A request to an account email without project or toSessionRef goes to its one live session, with several it is refused listing them (pick one with toSessionRef). A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
230
+ description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account), an account email or a session id; targetEmail names the account (with a role address it narrows to that account). kind: request|error = work inside the scope the recipient holds (request needs a target + outcome); info = information; complete = result (needs outcome). outcome: one sentence, 10 to 1000 characters; body: the plain text of the message (any length, lands in payload.body; payload may carry other JSON); threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; alone (no to, no targetEmail) a session id or short id addresses that session; project scopes an account message to sessions declared on it. A request to an account email without project or toSessionRef goes to its one live session, with several it is refused listing them (pick one with toSessionRef). A request no live session can receive is refused (no_recipient, naming the live sessions of the project; see orboto_who) unless queue: true holds it until a session declares the project; queued to a role address (worker@KEY) it waits for the first session that declares that role, and a queued request to an account address on the project of the owner address goes to the owner role. The answer names routedTo; routingNote "no session declared KEY" means nothing wakes - escalate. A request on a topic another role owns (orboto_who for) is refused; route: true sends it to the owner, forceTarget: true keeps the target. project is a key or UUID; senderRef defaults to this MCP session. An identical send (same recipient, project, kind, subject, payload; no threadId, no attachments) within the dedupe window (default 24 h) is not delivered again: the text says so and names the existing message. remind: true is a deliberate reminder of it (notifies again, at most one per 15 min, faster is refused with retryAfter); urgent: true is never suppressed and counts as a reminder. Loops and retries never set remind or urgent.',
167
231
  inputSchema: z.object({
168
232
  targetEmail: z.string().email().optional(),
169
233
  to: z.string().min(3).max(200).optional(),
@@ -235,8 +299,8 @@ export function notifyExample(kind, to) {
235
299
  export function notifyDefinitionError(args) {
236
300
  const kind = args.kind ?? 'info';
237
301
  const example = (text) => `Invalid arguments: ${text} Example: ${notifyExample(kind, args.to)}`;
238
- if (!args.to && !args.targetEmail)
239
- return example('name a recipient - `to` (integrator@KEY, an email or a session id) or `targetEmail`.');
302
+ if (!args.to && !args.targetEmail && !args.toSessionRef?.trim())
303
+ return example('name a recipient - `to` (integrator@KEY, an email or a session id), `targetEmail`, or `toSessionRef` (a session id or its short id).');
240
304
  const outcome = args.outcome?.trim() ?? '';
241
305
  if ((kind === 'request' || kind === 'complete') && !outcome) {
242
306
  return example(`\`outcome\` is required for a ${kind} - the outcome sentence (what must be true when it is done, or what was delivered), ${NOTIFY_OUTCOME_MIN} to ${NOTIFY_OUTCOME_MAX} characters.`);
@@ -275,7 +339,8 @@ export function makeAgentNotifyHandler(client) {
275
339
  }
276
340
  const who = res.ownerHint?.routed ? res.ownerHint.ownerAddress : args.to ?? args.targetEmail ?? '?';
277
341
  const recipients = (res.recipients ?? []).map((r) => r.label);
278
- const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${who} session ${res.toSessionId.slice(0, 8)}` : who;
342
+ const sessionOnly = !res.ownerHint?.routed && !args.to && !args.targetEmail;
343
+ const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${sessionOnly ? '' : `${who} `}session ${res.toSessionId.slice(0, 8)}` : who;
279
344
  const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
280
345
  const note = res.routingNote ? ` - ${res.routingNote}` : '';
281
346
  if (res.deduplicated) {
@@ -82,7 +82,7 @@ describe('orboto_agent_presence', () => {
82
82
  expect(text).toContain('2 active session(s)');
83
83
  expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
84
84
  expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle [orphaned] on build-host sample-repo pid 4242 claude-code orboto-cli 1.2.3; owner: Alice');
85
- expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory');
85
+ expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory&limit=4');
86
86
  expect(result.structuredContent.sessions).toHaveLength(2);
87
87
  });
88
88
  it('handles an empty workspace gracefully', async () => {
@@ -96,32 +96,56 @@ describe('orboto_agent_presence', () => {
96
96
  { userId: '00000000-0000-0000-0000-000000000002', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, autonomyPaused: false, lane: { id: '00000000-0000-0000-0000-000000000040', name: 'worker-1', role: 'implementation', paused: false }, projects: [{ id: '00000000-0000-0000-0000-000000000031', key: 'ACME', name: 'Acme' }, { id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'lane', label: 'worker-1' }, { type: 'live_events', label: 'MCP live events' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000011', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: { name: 'pi' }, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
97
97
  { userId: '00000000-0000-0000-0000-000000000001', userEmail: 'alice@x.test', userFullName: 'Alice', kind: 'agent', isBot: true, actsAs: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [{ type: 'oauth', label: 'claude.ai' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000012', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: {}, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
98
98
  ];
99
- it('filters by projectKey (case-insensitive, projects only) and kind on the client side (ORB-2135)', async () => {
99
+ it('ORB-2548: sends every filter to the one presence read, projectKey as project, four rows a page by default', async () => {
100
100
  const handler = makeAgentPresenceHandler(client);
101
- const ids = async (args) => {
102
- stub([{ json: { sessions: [], inventory: presenceRows() } }]);
103
- const result = await handler(args);
104
- expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory');
105
- return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
101
+ const url = async (args) => {
102
+ stub([{ json: { sessions: [], inventory: [] } }]);
103
+ await handler(args);
104
+ return String(vi.mocked(fetch).mock.calls.at(-1)?.[0]);
106
105
  };
107
- expect(await ids({})).toEqual(['10', '11', '12']);
108
- expect(await ids({ kind: 'agent' })).toEqual(['11', '12']);
109
- expect(await ids({ kind: 'human' })).toEqual(['10']);
110
- expect(await ids({ projectKey: 'acme' })).toEqual(['11']);
111
- expect(await ids({ projectKey: 'ORB', kind: 'human' })).toEqual(['10']);
112
- expect(await ids({ projectKey: 'NONE' })).toEqual([]);
113
- });
114
- it('says when a filter matched nothing but the workspace is not empty', async () => {
115
- stub([{ json: { sessions: [], inventory: presenceRows() } }]);
106
+ expect(await url({})).toBe('https://orboto.example.com/v1/agent/who?include=inventory&limit=4');
107
+ expect(await url({ projectKey: 'acme', kind: 'agent' })).toBe('https://orboto.example.com/v1/agent/who?include=inventory&project=acme&kind=agent&limit=4');
108
+ expect(await url({ email: 'w@x.test', sessionId: 'c785946f', role: 'worker', wake: 'on_call', state: 'live', limit: 2, cursor: 'next-1' }))
109
+ .toBe('https://orboto.example.com/v1/agent/who?include=inventory&role=worker&state=live&email=w%40x.test&sessionId=c785946f&wake=on_call&cursor=next-1&limit=2');
110
+ });
111
+ it('ORB-2548: a projectKey that is unknown or not visible answers in one line instead of a raw 404', async () => {
112
+ stub([{ ok: false, status: 404, json: { errorKey: 'errors.agents.message_project_not_found' } }]);
113
+ const result = await makeAgentPresenceHandler(client)({ projectKey: 'nope' });
114
+ expect(result.isError).toBeUndefined();
115
+ expect(result.content[0].text).toBe('Project "NOPE" is unknown or not visible to you - no session is listed for it.');
116
+ expect(result.structuredContent).toEqual({ sessions: [], nextCursor: null });
117
+ stub([{ ok: false, status: 404, json: {} }]);
118
+ await expect(makeAgentPresenceHandler(client)({ email: 'x@y.test' })).rejects.toThrow();
119
+ });
120
+ it('says when a filter matched nothing', async () => {
121
+ stub([{ json: { sessions: [], inventory: [] } }]);
116
122
  const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
117
- expect(result.content[0].text).toBe('No active agent sessions match the filter (3 active in the workspace).');
123
+ expect(result.content[0].text).toBe('No active agent session matches the filter.');
118
124
  });
119
- it('renders acts-as, lane, projects and connection types on each line', async () => {
120
- stub([{ json: { sessions: [], inventory: presenceRows() } }]);
121
- const text = (await makeAgentPresenceHandler(client)({ kind: 'agent' })).content[0].text;
122
- expect(text).toContain('2 active session(s)');
123
- expect(text).toContain('owner: Alice; lane: worker-1; projects: ACME, ORB; connections: lane, live_events');
125
+ it('renders acts-as, lane, scope, projects and connection types on each line and returns the compact row', async () => {
126
+ const rows = presenceRows().filter((row) => row.kind === 'agent').map((row, i) => (i === 0 ? { ...row, scope: { role: 'worker', projectKeys: ['ACME'] }, wake: 'channel' } : row));
127
+ stub([{ json: { sessions: [], inventory: rows, nextCursor: 'cursor-2' } }]);
128
+ const result = await makeAgentPresenceHandler(client)({ kind: 'agent' });
129
+ const text = result.content[0].text;
130
+ expect(text).toContain('2 active session(s) on this page');
131
+ expect(text).toContain('owner: Alice; lane: worker-1; scope: worker on ACME; projects: ACME, ORB; connections: lane, live_events; reachable by channel');
124
132
  expect(text).toContain('owner: acts as Alice; connections: oauth');
133
+ expect(text).toContain('More sessions: call again with cursor "cursor-2"');
134
+ const structured = result.structuredContent;
135
+ expect(structured.nextCursor).toBe('cursor-2');
136
+ expect(structured.sessions[0]).toEqual({
137
+ sessionId: '00000000-0000-0000-0000-000000000011', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', owner: 'Alice',
138
+ status: 'idle', lane: 'worker-1', scope: { role: 'worker', projectKeys: ['ACME'] }, memberProjects: 2, connections: ['lane', 'live_events'],
139
+ wake: 'channel', origin: null, workingOn: null, lastSeenAt: rows[0].lastSeenAt,
140
+ });
141
+ expect(structured.sessions[1]).toMatchObject({ owner: 'acts as Alice', memberProjects: 0, connections: ['oauth'] });
142
+ });
143
+ it('names the member count instead of listing more than eight projects', async () => {
144
+ const many = Array.from({ length: 99 }, (_, i) => ({ id: '00000000-0000-0000-0000-000000000030', key: `P${i}`, name: `Project ${i}` }));
145
+ stub([{ json: { sessions: [], inventory: [{ ...presenceRows()[1], projects: many }] } }]);
146
+ const text = (await makeAgentPresenceHandler(client)({})).content[0].text;
147
+ expect(text).toContain('; member of 99 projects;');
148
+ expect(text).not.toContain('P98');
125
149
  });
126
150
  });
127
151
  describe('orboto_agent_notify', () => {
@@ -263,6 +287,22 @@ describe('ORB-2136 - session addressing', () => {
263
287
  expect(result.content[0].text).toContain('bob@example.com session cbb52195');
264
288
  expect(result.structuredContent).toMatchObject({ toSessionId: 'cbb52195-0000-4000-8000-000000000000' });
265
289
  });
290
+ it('ORB-2548: toSessionRef alone is a recipient - sent without to or targetEmail, the answer names the session', async () => {
291
+ const seen = [];
292
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
293
+ seen.push(JSON.parse(init?.body ?? '{}'));
294
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: 'cbb52195-0000-4000-8000-000000000000', routedTo: 'session' }), text: async () => '' };
295
+ });
296
+ const result = await makeAgentNotifyHandler(client)({ subject: 'hi', toSessionRef: 'cbb52195' }, { sessionId: 'abc123' });
297
+ expect(result.isError).toBeUndefined();
298
+ expect(seen[0]).toMatchObject({ toSessionRef: 'cbb52195', subject: 'hi' });
299
+ expect(seen[0]).not.toHaveProperty('to');
300
+ expect(result.content[0].text).toContain('notified session cbb52195 (message');
301
+ vi.restoreAllMocks();
302
+ const none = await makeAgentNotifyHandler(client)({ subject: 'hi' });
303
+ expect(none.isError).toBe(true);
304
+ expect(none.content[0].text).toContain('`toSessionRef` (a session id or its short id)');
305
+ });
266
306
  it('orboto_messages identifies this session on the fetch and renders sender and receiver identities', async () => {
267
307
  const seen = [];
268
308
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { z } from 'zod';
8
8
  import { sizeBlockResult } from './shared.js';
9
+ const AGENTS_PROPOSE = ' Agents propose shared rules with orboto_rule_propose (403 errors.agent_rules.write_needs_person).';
9
10
  const RULE_WRITING_CONTRACT = ' Rule: title = one imperative sentence; body <= 400 chars, Rule / Why / How, no long examples, nothing another block says.';
10
11
  function excerptOf(body) {
11
12
  const firstLine = body.split('\n', 1)[0] ?? '';
@@ -74,7 +75,7 @@ export function makeListAgentInstructionsHandler(client) {
74
75
  export const createAgentInstructionToolConfig = {
75
76
  title: 'Add a custom coding-agent rule block',
76
77
  description: 'Create a custom rule block at a scope (workspace, customer, project or personal). Workspace reaches every agent (admin:ai:write), customer every project of one customer (customer:write), project one project (project:edit), personal only you. For scope=customer pass customerId; for scope=project pass projectId.'
77
- + RULE_WRITING_CONTRACT,
78
+ + AGENTS_PROPOSE + RULE_WRITING_CONTRACT,
78
79
  inputSchema: z.object({
79
80
  title: z.string().min(1).max(120),
80
81
  body: z.string().min(1).max(8000).describe('The rule text the agent should follow.'),
@@ -109,7 +110,7 @@ export function makeCreateAgentInstructionHandler(client) {
109
110
  export const updateAgentInstructionToolConfig = {
110
111
  title: 'Edit / toggle / reorder a coding-agent rule block',
111
112
  description: 'Patch a rule block: title, body, enabled (toggle a rule on/off), or sortOrder. Works on default AND custom blocks (defaults keep their builtinKey and can be reset later). Needs admin:ai:write.'
112
- + RULE_WRITING_CONTRACT,
113
+ + AGENTS_PROPOSE + RULE_WRITING_CONTRACT,
113
114
  inputSchema: z.object({
114
115
  id: z.string().uuid(),
115
116
  title: z.string().min(1).max(120).optional(),
@@ -141,7 +142,7 @@ export function makeUpdateAgentInstructionHandler(client) {
141
142
  }
142
143
  export const resetAgentInstructionToolConfig = {
143
144
  title: 'Reset a default rule block to its shipped text',
144
- description: 'Restore a seeded DEFAULT rule block (one with a builtinKey) to the text orboto ships. No-op for custom blocks. Needs admin:ai:write.',
145
+ description: 'Restore a seeded DEFAULT rule block (one with a builtinKey) to the text orboto ships. No-op for custom blocks. Needs admin:ai:write.' + AGENTS_PROPOSE,
145
146
  inputSchema: z.object({ id: z.string().uuid() }).shape,
146
147
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
147
148
  };
@@ -153,7 +154,7 @@ export function makeResetAgentInstructionHandler(client) {
153
154
  }
154
155
  export const deleteAgentInstructionToolConfig = {
155
156
  title: 'Delete a custom coding-agent rule block',
156
- description: 'Delete a CUSTOM rule block. Default (builtin) blocks cannot be deleted - disable them via update instead. Needs admin:ai:write.',
157
+ description: 'Delete a CUSTOM rule block. Default (builtin) blocks cannot be deleted - disable them via update instead. Needs admin:ai:write.' + AGENTS_PROPOSE,
157
158
  inputSchema: z.object({ id: z.string().uuid() }).shape,
158
159
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
159
160
  };