@orboto/mcp 0.221.3 → 0.222.0

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 (39) hide show
  1. package/dist/event-bridge.js +2 -0
  2. package/dist/http-transport.js +28 -6
  3. package/dist/inbox-channel.js +14 -11
  4. package/dist/instructions-budget.test.js +72 -2
  5. package/dist/manifest-size.test.js +8 -0
  6. package/dist/orboto-client.js +3 -1
  7. package/dist/orboto-client.test.js +1 -1
  8. package/dist/server.js +18 -28
  9. package/dist/session-origin.js +100 -0
  10. package/dist/session-origin.test.js +127 -0
  11. package/dist/tools/agent-coordination.js +39 -11
  12. package/dist/tools/agent-coordination.test.js +53 -35
  13. package/dist/tools/agent-messages.js +16 -5
  14. package/dist/tools/agent-who.js +23 -8
  15. package/dist/tools/agent-who.test.js +12 -0
  16. package/dist/tools/check-similar.js +5 -3
  17. package/dist/tools/doc-edits.js +6 -4
  18. package/dist/tools/doc-replace-wrapper.test.js +81 -0
  19. package/dist/tools/docs-ai.js +4 -0
  20. package/dist/tools/docs.js +25 -14
  21. package/dist/tools/docs.test.js +25 -12
  22. package/dist/tools/embedding-status.js +3 -0
  23. package/dist/tools/knowledge.js +2 -2
  24. package/dist/tools/knowledge.test.js +8 -0
  25. package/dist/tools/list-tickets.js +12 -9
  26. package/dist/tools/phase-b-tools.test.js +18 -0
  27. package/dist/tools/query.js +1 -1
  28. package/dist/tools/responsibilities.js +10 -0
  29. package/dist/tools/responsibilities.test.js +60 -0
  30. package/dist/tools/search.js +5 -3
  31. package/dist/tools/semantic-degraded-wrapper.test.js +70 -0
  32. package/dist/tools/semantic-degraded.test.js +65 -0
  33. package/dist/tools/session-start.js +8 -2
  34. package/dist/tools/shared.js +31 -3
  35. package/dist/tools/spec-schemas.js +2 -0
  36. package/dist/tools/wiki.js +15 -9
  37. package/dist/tools/wiki.test.js +23 -0
  38. package/dist/toolset.js +3 -2
  39. package/package.json +1 -1
@@ -43,9 +43,16 @@ export function makeAgentHeartbeatHandler(client) {
43
43
  };
44
44
  };
45
45
  }
46
+ export function originText(s) {
47
+ if (!s.origin)
48
+ return '';
49
+ const o = s.origin;
50
+ 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(' ');
51
+ return ` [${s.state ?? 'live'}] on ${where}${o.startedAt ? ` since ${o.startedAt}` : ''}`;
52
+ }
46
53
  export const agentPresenceToolConfig = {
47
54
  title: 'Workspace agent presence',
48
- description: 'Returns all active orboto agent instances: lane workers/reviewers, inbox and MCP connections, registered runners. Session freshness is 90 seconds; lanes retain their 300-second heartbeat grace. Unexpired work leases keep their owning instance visible. Ticket metadata and claims retain ordinary ticket visibility. Rows include `kind` (agent or human, classified by the credential: agent API key, OAuth grant, lane, external token or bot account; `isBot` is its alias), `actsAs` (the human account an AI client acts under), `projects` (memberships plus lane projects), `connections` (type + label per credential or attachment, `live_events` for the MCP event bridge), owner, lane and stable sessionId; separate instances of one account remain separate. Callers with admin:system:read see every agent; other users see only their own sessions. Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, the ticket it works on, and `reconnects` survived under the same id and scope. Check it before dispatching a sub-task so you do not step on a parallel run. Filters `projectKey` (rows whose `projects` contain the key) and `kind` (agent | human) narrow the list client-side, the same way `orboto agents --project KEY --bots|--humans` does.',
55
+ description: 'Active agent instances. The orboto_who set: lane workers and reviewers, inbox, MCP and runner connections; rows carry kind (agent | human, by credential; isBot is its alias), actsAs, owner, projects, connections, lane, sessionId, userEmail, runtime (clientInfo.name), capabilities, status, current ticket, reconnects, origin (host, repo, pid, client, harness, credential) and state (live | quiet | orphaned | unknown_origin). Operators (admin:agents:read or admin:system:read) see the workspace, others their own sessions and those declared on their projects, a foreign row redacted (redacted: true, no pid, repo, machine, connections, inbox or owner). Check it before dispatching a sub-task. projectKey and kind filter client-side like `orboto agents --project KEY --bots|--humans`.',
49
56
  inputSchema: z.object({
50
57
  projectKey: z.string().min(1).max(64).optional().describe('Keep only rows whose projects contain this project key (case-insensitive).'),
51
58
  kind: AgentInventoryKindSchema.optional().describe('Keep only rows classified as agent or human by their credential.'),
@@ -62,7 +69,7 @@ export function filterPresenceRows(rows, args) {
62
69
  }
63
70
  export function makeAgentPresenceHandler(client) {
64
71
  return async (args = {}) => {
65
- const all = await client.get('/v1/agent/inventory');
72
+ const all = (await client.get('/v1/agent/who?include=inventory')).inventory ?? [];
66
73
  const sessions = filterPresenceRows(all, args);
67
74
  const lines = [];
68
75
  if (sessions.length === 0) {
@@ -76,7 +83,7 @@ export function makeAgentPresenceHandler(client) {
76
83
  const work = s.workingOnTicket
77
84
  ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
78
85
  : '';
79
- lines.push(`- ${name} <${s.userEmail}> (${runtime}, instance ${s.sessionId}) - ${s.status}${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(', ')}` : ''}`);
86
+ 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(', ')}` : ''}`);
80
87
  }
81
88
  }
82
89
  return {
@@ -106,9 +113,26 @@ export function noRecipientText(err) {
106
113
  + (live.length ? `Live sessions: ${live.join('; ')}. ` : 'No live session declared the project. ')
107
114
  + 'Address one of them, or send again with queue: true and project to keep it until a session declares the project.';
108
115
  }
116
+ /** ORB-2542 - the 409 `owner_mismatch` as a tool error naming the owner and both ways on. */
117
+ export function ownerMismatchText(err) {
118
+ if (err.status !== 409)
119
+ return null;
120
+ let body;
121
+ try {
122
+ body = JSON.parse(err.body);
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ if (body.errorKey !== 'errors.agents.owner_mismatch')
128
+ return null;
129
+ const p = body.errorParams ?? {};
130
+ const text = p.ownerHint?.text ?? `"${p.topic ?? '?'}" belongs to ${p.owner ?? '?'}, not ${p.target ?? '?'}`;
131
+ return `not sent: ${text}. Send again with route: true to deliver it to ${p.owner ?? 'the owner'}, or forceTarget: true to keep the target.`;
132
+ }
109
133
  export const agentNotifyToolConfig = {
110
- title: 'Notify another agent / user',
111
- description: 'Message an agent or user. `to` takes a role address integrator@KEY (every live session with that role on the project, any account, each as addressed mail), 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). payload: free-form 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 (ORB-2136). A request no live session can receive is refused (no_recipient, naming the live sessions of the project; orboto_who lists them before sending) unless queue: true keeps it in the inbox until a session declares the project (ORB-2264). The answer names routedTo (session, scope or account); routingNote "no session declared KEY" means nothing wakes - escalate instead of assuming delivery (ORB-2263).',
134
+ title: 'Notify an agent or user',
135
+ 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). payload: free-form 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 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.',
112
136
  inputSchema: z.object({
113
137
  targetEmail: z.string().email().optional(),
114
138
  to: z.string().min(3).max(200).optional(),
@@ -118,9 +142,11 @@ export const agentNotifyToolConfig = {
118
142
  outcome: z.string().min(10).max(1000).optional(),
119
143
  payload: z.record(z.string(), z.unknown()).optional(),
120
144
  threadId: z.string().uuid().optional(),
121
- project: z.string().min(1).max(64).optional().describe('Project key or UUID.'),
122
- senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref; default: this MCP session.'),
145
+ project: z.string().min(1).max(64).optional(),
146
+ senderRef: z.string().min(1).max(128).optional(),
123
147
  toSessionRef: z.string().min(1).max(200).optional(),
148
+ route: z.boolean().optional(),
149
+ forceTarget: z.boolean().optional(),
124
150
  }).shape,
125
151
  outputSchema: z.object({
126
152
  ok: z.literal(true),
@@ -130,6 +156,7 @@ export const agentNotifyToolConfig = {
130
156
  routingNote: z.string().optional(),
131
157
  recipients: z.array(z.string()).optional(),
132
158
  queued: z.boolean().optional(),
159
+ owner: z.string().optional(),
133
160
  }).shape,
134
161
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
135
162
  };
@@ -178,26 +205,27 @@ export function makeAgentNotifyHandler(client) {
178
205
  const senderRef = mcpInstanceToken(args.senderRef, extra);
179
206
  let res;
180
207
  try {
181
- res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
208
+ res = await client.post('/v1/agent/notify', { ...args, senderRef, ownerCheck: 'refuse' }, { instanceToken: mcpInstanceToken(undefined, extra) });
182
209
  }
183
210
  catch (err) {
184
- const refused = err instanceof OrbotoApiError ? noRecipientText(err) : null;
211
+ const refused = err instanceof OrbotoApiError ? noRecipientText(err) ?? ownerMismatchText(err) : null;
185
212
  if (refused)
186
213
  return { isError: true, content: [{ type: 'text', text: refused }] };
187
214
  throw err;
188
215
  }
189
- const who = args.to ?? args.targetEmail ?? '?';
216
+ const who = res.ownerHint?.routed ? res.ownerHint.ownerAddress : args.to ?? args.targetEmail ?? '?';
190
217
  const recipients = (res.recipients ?? []).map((r) => r.label);
191
218
  const target = recipients.length > 1 ? recipients.join(', ') : res.toSessionId ? `${who} session ${res.toSessionId.slice(0, 8)}` : who;
192
219
  const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
193
220
  const note = res.routingNote ? ` - ${res.routingNote}` : '';
194
221
  return {
195
- content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.hint ? `\nhint: ${res.hint}` : ''}` }],
222
+ content: [{ type: 'text', text: `${res.queued ? 'queued for' : 'notified'} ${target} (message ${res.messageId}, routed to ${routedTo}${note})${res.ownerHint?.routed ? `\nrouted to the owner of "${res.ownerHint.topic}" instead of ${res.ownerHint.target}` : ''}${res.hint ? `\nhint: ${res.hint}` : ''}` }],
196
223
  structuredContent: {
197
224
  ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo,
198
225
  ...(res.routingNote ? { routingNote: res.routingNote } : {}),
199
226
  ...(recipients.length ? { recipients } : {}),
200
227
  ...(res.queued ? { queued: true } : {}),
228
+ ...(res.ownerHint?.routed ? { owner: res.ownerHint.ownerAddress } : {}),
201
229
  },
202
230
  };
203
231
  };
@@ -44,47 +44,49 @@ describe('orboto_agent_heartbeat', () => {
44
44
  });
45
45
  });
46
46
  describe('orboto_agent_presence', () => {
47
- it('GETs /v1/agent/inventory and renders one line per session', async () => {
47
+ it('GETs the inventory through /v1/agent/who and renders one line per session with its origin and state (ORB-2544)', async () => {
48
48
  stub([{
49
- json: [
50
- {
51
- userId: '00000000-0000-0000-0000-000000000001',
52
- userEmail: 'alice@x.test',
53
- userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [], workSessions: [],
54
- sessionId: '00000000-0000-0000-0000-000000000010',
55
- status: 'working',
56
- workingOnTicket: { id: '00000000-0000-0000-0000-000000000020', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
57
- capabilities: ['writes-tickets'],
58
- clientInfo: { name: 'claude-code' },
59
- lastSeenAt: new Date().toISOString(),
60
- createdAt: new Date().toISOString(),
61
- },
62
- {
63
- userId: '00000000-0000-0000-0000-000000000002',
64
- userEmail: 'bot@x.test',
65
- userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' },
66
- autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'dispatcher' }], workSessions: [],
67
- sessionId: '00000000-0000-0000-0000-000000000011',
68
- status: 'idle',
69
- workingOnTicket: null,
70
- capabilities: [],
71
- clientInfo: { name: 'dispatcher-daemon' },
72
- lastSeenAt: new Date().toISOString(),
73
- createdAt: new Date().toISOString(),
74
- },
75
- ],
49
+ json: { sessions: [], inventory: [
50
+ {
51
+ userId: '00000000-0000-0000-0000-000000000001',
52
+ userEmail: 'alice@x.test',
53
+ userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [], workSessions: [],
54
+ sessionId: '00000000-0000-0000-0000-000000000010',
55
+ status: 'working',
56
+ workingOnTicket: { id: '00000000-0000-0000-0000-000000000020', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
57
+ capabilities: ['writes-tickets'],
58
+ clientInfo: { name: 'claude-code' },
59
+ lastSeenAt: new Date().toISOString(),
60
+ createdAt: new Date().toISOString(),
61
+ },
62
+ {
63
+ userId: '00000000-0000-0000-0000-000000000002',
64
+ userEmail: 'bot@x.test',
65
+ userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' },
66
+ autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'dispatcher' }], workSessions: [],
67
+ sessionId: '00000000-0000-0000-0000-000000000011',
68
+ status: 'idle',
69
+ workingOnTicket: null,
70
+ capabilities: [],
71
+ clientInfo: { name: 'dispatcher-daemon' },
72
+ lastSeenAt: new Date().toISOString(),
73
+ createdAt: new Date().toISOString(),
74
+ origin: { host: 'build-host', repo: 'sample-repo', pid: 4242, client: 'orboto-cli', clientVersion: '1.2.3', harness: 'claude-code', startedAt: null },
75
+ state: 'orphaned',
76
+ },
77
+ ] },
76
78
  }]);
77
79
  const handler = makeAgentPresenceHandler(client);
78
80
  const result = await handler();
79
81
  const text = result.content[0].text;
80
82
  expect(text).toContain('2 active session(s)');
81
83
  expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
82
- expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle; owner: Alice');
83
- expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/inventory');
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');
84
86
  expect(result.structuredContent.sessions).toHaveLength(2);
85
87
  });
86
88
  it('handles an empty workspace gracefully', async () => {
87
- stub([{ json: [] }]);
89
+ stub([{ json: { sessions: [], inventory: [] } }]);
88
90
  const handler = makeAgentPresenceHandler(client);
89
91
  const result = await handler();
90
92
  expect(result.content[0].text).toBe('No active agent sessions in the workspace.');
@@ -97,9 +99,9 @@ describe('orboto_agent_presence', () => {
97
99
  it('filters by projectKey (case-insensitive, projects only) and kind on the client side (ORB-2135)', async () => {
98
100
  const handler = makeAgentPresenceHandler(client);
99
101
  const ids = async (args) => {
100
- stub([{ json: presenceRows() }]);
102
+ stub([{ json: { sessions: [], inventory: presenceRows() } }]);
101
103
  const result = await handler(args);
102
- expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/inventory');
104
+ expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/who?include=inventory');
103
105
  return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
104
106
  };
105
107
  expect(await ids({})).toEqual(['10', '11', '12']);
@@ -110,12 +112,12 @@ describe('orboto_agent_presence', () => {
110
112
  expect(await ids({ projectKey: 'NONE' })).toEqual([]);
111
113
  });
112
114
  it('says when a filter matched nothing but the workspace is not empty', async () => {
113
- stub([{ json: presenceRows() }]);
115
+ stub([{ json: { sessions: [], inventory: presenceRows() } }]);
114
116
  const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
115
117
  expect(result.content[0].text).toBe('No active agent sessions match the filter (3 active in the workspace).');
116
118
  });
117
119
  it('renders acts-as, lane, projects and connection types on each line', async () => {
118
- stub([{ json: presenceRows() }]);
120
+ stub([{ json: { sessions: [], inventory: presenceRows() } }]);
119
121
  const text = (await makeAgentPresenceHandler(client)({ kind: 'agent' })).content[0].text;
120
122
  expect(text).toContain('2 active session(s)');
121
123
  expect(text).toContain('owner: Alice; lane: worker-1; projects: ACME, ORB; connections: lane, live_events');
@@ -228,6 +230,22 @@ describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
228
230
  expect(urls[0]).toContain('excludeRef=mcp-abc123');
229
231
  await handler({ includeOwnSends: true }, { sessionId: 'abc123' });
230
232
  expect(urls[1]).not.toContain('excludeRef');
233
+ expect(urls[1]).toContain('includeOwn=true');
234
+ });
235
+ it('ORB-2547: an own send renders as sent with the recipient session, its delivered/read state and the thread', async () => {
236
+ const party = (label, sessionId) => ({ userId: 'u', email: 'e', sessionId, role: null, label });
237
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
238
+ ok: true, status: 200, statusText: 'OK',
239
+ json: async () => ({ sessionId: '11111111-1111-4111-8111-111111111111', messages: [
240
+ { id: '22222222-2222-4222-8222-222222222222', fromUserId: 'u', toUserId: 'v', from: party('me (11111111)', '11111111'), to: party('peer (44444444)', '44444444'), kind: 'complete', subject: 'done', payload: null, threadId: '33333333-3333-4333-8333-333333333333', projectKey: 'ALPHA', createdAt: '2026-10-16T10:00:00Z', deliveredAt: '2026-10-16T10:00:05Z', readAt: null, dismissed: null, own: true },
241
+ ] }),
242
+ text: async () => '',
243
+ }));
244
+ const result = await makeAgentMessagesHandler(client)({ includeOwnSends: true }, { sessionId: 'abc123' });
245
+ const text = result.content[0].text;
246
+ expect(text).toContain('[sent] delivered 2026-10-16T10:00:05Z, unread [complete] [ALPHA] done (from me (11111111) to peer (44444444)');
247
+ expect(text).toContain('thread 33333333-3333-4333-8333-333333333333');
248
+ expect(result.structuredContent.messages[0]).toMatchObject({ own: true, deliveredAt: '2026-10-16T10:00:05Z', threadId: '33333333-3333-4333-8333-333333333333' });
231
249
  });
232
250
  });
233
251
  describe('ORB-2136 - session addressing', () => {
@@ -14,16 +14,16 @@ const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId:
14
14
  const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
15
15
  export const agentMessagesToolConfig = {
16
16
  title: 'Agent inbox and message work',
17
- description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true is the account view with every message in full (ORB-2136). By default it lists the newest 10 unread, newest first, each as a header plus the first 300 payload characters, then the line `N unread, M older than 7 days`; limit and since (ISO time or message id) narrow it (ORB-2502). Before orboto_session_start only account mail is listed; register to see mail addressed to your session (ORB-2347). messageWork carries durable work ownership - claim before executing. ackIds acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
17
+ description: 'Your inbox as THIS session sees it. It holds mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out (includeOwnSends lists your sends with recipient session, delivered/read state, thread), all:true is the account view in full (ORB-2136). Default: the newest 10 unread, newest first, a header plus 300 payload characters each, then `N unread, M older than 7 days`; limit and since (ISO time or message id) narrow it. Before orboto_session_start only account mail is listed; register to see mail addressed to your session (ORB-2347). messageWork carries durable work ownership (mutation schema: orboto_api_search) - claim before executing. ackIds (message ids) acks: it completes an unclaimed request; claimed work with open steps is refused until messageWork completes or blocks it. dismiss: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId).',
18
18
  inputSchema: z.object({
19
19
  messageWork: z.record(z.unknown()).optional()
20
- .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
20
+ .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}.'),
21
21
  all: z.boolean().default(false),
22
22
  limit: z.number().int().min(1).max(200).optional(),
23
23
  since: z.string().optional(),
24
24
  project: z.string().min(1).max(64).optional().describe('Key/UUID; includes unscoped mail.'),
25
25
  includeOwnSends: z.boolean().default(false),
26
- ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark read.'),
26
+ ackIds: z.array(z.string().uuid()).max(200).optional(),
27
27
  dismiss: z.object({
28
28
  ids: z.array(z.string().uuid()).min(1).max(200),
29
29
  reason: z.enum(['not_mine', 'obsolete', 'duplicate']),
@@ -45,7 +45,9 @@ export const agentMessagesToolConfig = {
45
45
  threadId: z.string().nullable(),
46
46
  projectKey: z.string().nullable(),
47
47
  createdAt: z.string(),
48
+ deliveredAt: z.string().nullable().optional(),
48
49
  readAt: z.string().nullable(),
50
+ own: z.boolean().optional(),
49
51
  dismissed: z.object({
50
52
  reason: z.string(), note: z.string().nullable(), duplicateOf: z.string().nullable(), createdAt: z.string(),
51
53
  }).nullable(),
@@ -66,6 +68,12 @@ function compactLines(m, header) {
66
68
  ...(rest > 0 ? [` ... ${rest} more characters (all:true prints everything)`] : []),
67
69
  ];
68
70
  }
71
+ /** ORB-2547 - an own send's state at the recipient. */
72
+ function sentState(m) {
73
+ if (m.readAt)
74
+ return `read ${m.readAt}`;
75
+ return m.deliveredAt ? `delivered ${m.deliveredAt}, unread` : 'not delivered yet';
76
+ }
69
77
  export function makeAgentMessagesHandler(client) {
70
78
  return async (args, extra) => {
71
79
  if (args.messageWork) {
@@ -103,10 +111,12 @@ export function makeAgentMessagesHandler(client) {
103
111
  if (args.project)
104
112
  q.set('project', args.project);
105
113
  const instanceToken = mcpInstanceToken(undefined, extra);
106
- if (!args.includeOwnSends)
114
+ if (args.includeOwnSends)
115
+ q.set('includeOwn', 'true');
116
+ else
107
117
  q.set('excludeRef', instanceToken);
108
118
  const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
109
- const header = (m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
119
+ const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
110
120
  const lines = messages.length === 0
111
121
  ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
112
122
  : messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
@@ -130,6 +140,7 @@ export function makeAgentMessagesHandler(client) {
130
140
  id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
131
141
  payload: compact ? null : m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
132
142
  createdAt: m.createdAt, readAt: m.readAt, dismissed: m.dismissed ?? null,
143
+ ...(m.own ? { own: true, deliveredAt: m.deliveredAt ?? null } : {}),
133
144
  })),
134
145
  acked,
135
146
  dismissed: dismissedCount,
@@ -1,14 +1,18 @@
1
1
  /** ORB-2264 - the live directory: who a sender can address before sending. */
2
2
  import { z } from 'zod';
3
3
  import { AgentSessionRoleSchema } from './agent-session-scope.js';
4
+ import { ownerLine } from './responsibilities.js';
5
+ import { originText } from './agent-coordination.js';
4
6
  export const agentWhoToolConfig = {
5
- title: 'Who can receive agent mail',
6
- description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log.',
7
+ title: 'Who receives agent mail',
8
+ description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. Each row names account, role, declared projects, last seen, wake channel and current ticket; address a role from here, never a session id copied from a log. `for` (free text) names the topic owner and keeps its sessions; `state` keeps one state (orphaned: only another process touches it).',
7
9
  inputSchema: z.object({
8
10
  project: z.string().min(1).max(64).optional(),
9
11
  role: AgentSessionRoleSchema.optional(),
12
+ for: z.string().min(2).max(2000).optional(),
13
+ state: z.enum(['live', 'quiet', 'orphaned', 'unknown_origin']).optional(),
10
14
  }).shape,
11
- outputSchema: z.object({ sessions: z.array(z.record(z.string(), z.unknown())) }).shape,
15
+ outputSchema: z.object({ sessions: z.array(z.unknown()), owner: z.unknown().optional() }).shape,
12
16
  annotations: { readOnlyHint: true, idempotentHint: true },
13
17
  };
14
18
  /** One directory row as a line: address, account, session, projects, channel, ticket. */
@@ -16,20 +20,31 @@ export function whoLine(s) {
16
20
  const address = s.addresses.join(', ') || (s.role ? `${s.role} (no project)` : 'no scope');
17
21
  const channel = s.channel.announced ? `${s.channel.announced}${s.channel.online ? ' online' : ' offline'}` : 'no channel';
18
22
  const ticket = s.currentTicket ? `; on ${s.currentTicket.key ?? s.currentTicket.title}` : '';
19
- const host = s.host ? `, host ${s.host}` : '';
20
- return `- ${address} - ${s.email} session ${s.shortId}${host}, ${s.status}, ${channel}, last seen ${s.lastSeenAt}${ticket}`;
23
+ const host = s.origin ? originText(s) : s.host ? `, host ${s.host}` : '';
24
+ const shared = s.sharedAddresses?.length ? `; also held by another live session: ${s.sharedAddresses.join(', ')}` : '';
25
+ return `- ${address} - ${s.email} session ${s.shortId}${host}, ${s.status}, ${channel}, last seen ${s.lastSeenAt}${ticket}${shared}`;
21
26
  }
22
27
  export function makeAgentWhoHandler(client) {
23
28
  return async (args = {}) => {
24
29
  const qs = new URLSearchParams();
30
+ if (args.state)
31
+ qs.set('state', args.state);
25
32
  if (args.project)
26
33
  qs.set('project', args.project);
27
34
  if (args.role)
28
35
  qs.set('role', args.role);
36
+ if (args.for)
37
+ qs.set('for', args.for);
29
38
  const res = await client.get(`/v1/agent/who${qs.size ? `?${qs.toString()}` : ''}`);
39
+ const ownerLines = args.for
40
+ ? [res.owner ? `Owner:\n${ownerLine(res.owner)}` : `No entry in the responsibility map matches "${args.for}".`]
41
+ : [];
30
42
  const lines = res.sessions.length
31
- ? [`${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
32
- : ['No live session matches - a request sent now answers no_recipient unless it is queued.'];
33
- return { content: [{ type: 'text', text: lines.join('\n') }], structuredContent: { sessions: res.sessions } };
43
+ ? [...ownerLines, `${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
44
+ : [...ownerLines, res.owner ? `No live session answers to ${res.owner.entry.ownerAddress} - send with queue: true and project ${res.owner.entry.ownerAddress.split('@')[1]} to keep it until one declares it.` : 'No live session matches - a request sent now answers no_recipient unless it is queued.'];
45
+ return {
46
+ content: [{ type: 'text', text: lines.join('\n') }],
47
+ structuredContent: { sessions: res.sessions, ...(args.for ? { owner: (res.owner ?? null) } : {}) },
48
+ };
34
49
  };
35
50
  }
@@ -31,4 +31,16 @@ describe('orboto_who', () => {
31
31
  it('ORB-2465: names the host a live notice filters on', () => {
32
32
  expect(whoLine({ ...entry, host: 'build-host-a' })).toContain('session f072e1c5, host build-host-a, working');
33
33
  });
34
+ it('ORB-2544: prints origin, state and a shared address, and passes the state filter', async () => {
35
+ const line = whoLine({ ...entry, state: 'orphaned', sharedAddresses: ['integrator@ORB'], origin: { host: 'build-host', repo: 'sample-repo', pid: 4242, client: 'orboto-cli', clientVersion: '1.2.3', harness: 'claude-code', startedAt: '2026-10-16T08:00:00.000Z' } });
36
+ expect(line).toContain('session f072e1c5 [orphaned] on build-host sample-repo pid 4242 claude-code orboto-cli 1.2.3 since 2026-10-16T08:00:00.000Z, working');
37
+ expect(line).toContain('also held by another live session: integrator@ORB');
38
+ const urls = [];
39
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
40
+ urls.push(String(url));
41
+ return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) };
42
+ });
43
+ await makeAgentWhoHandler(client)({ state: 'unknown_origin' });
44
+ expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?state=unknown_origin');
45
+ });
34
46
  });
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { z } from 'zod';
5
5
  import { trimSimilarEntries } from './similar-projection.js';
6
- import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
6
+ import { resolveProjectByKey, resolveTicketByKey, SEMANTIC_DEGRADED_LINE } from './shared.js';
7
7
  export const checkSimilarToolConfig = {
8
8
  title: 'Check for similar tickets before creating',
9
9
  description: 'Run the duplicate check against a proposed title + description without creating anything. The pipeline is tsvector plus an AI-embedding rerank when configured. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. Only an AI-embedding candidate carries an absolute similarity; a full-text candidate prints as "text match" without a percentage (its rank is relative, so the best text match always scores 1.000) and can never trigger the hard duplicate-block, and neither can a match against a CLOSED ticket (ORB-2185). An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
@@ -43,7 +43,8 @@ export function makeCheckSimilarHandler(client) {
43
43
  : strong
44
44
  ? `HIGH-SIMILARITY MATCH FOUND - review [${strong.ticketKey ?? strong.id.slice(0, 8)}] "${strong.title}" before creating; this may already be tracked.`
45
45
  : 'Possible related tickets - review the list and decide whether the new ticket adds distinct scope.';
46
- const text = result.candidates.length === 0
46
+ const degradedNote = result.semanticDegraded ? `(${SEMANTIC_DEGRADED_LINE})\n` : '';
47
+ const text = degradedNote + (result.candidates.length === 0
47
48
  ? `${recommendation} (match mode: ${result.mode})`
48
49
  : [
49
50
  recommendation,
@@ -55,12 +56,13 @@ export function makeCheckSimilarHandler(client) {
55
56
  const rel = c.relation ? ` (related: ${c.relation})` : '';
56
57
  return ` - ${key}${status} (${score})${rel}: ${c.title}`;
57
58
  }),
58
- ].join('\n');
59
+ ].join('\n'));
59
60
  return {
60
61
  content: [{ type: 'text', text }],
61
62
  structuredContent: {
62
63
  similar: trimSimilarEntries(result.candidates),
63
64
  mode: result.mode,
65
+ semanticDegraded: result.semanticDegraded ?? false,
64
66
  recommendation,
65
67
  },
66
68
  };
@@ -6,7 +6,7 @@
6
6
  import { z } from 'zod';
7
7
  import { OrbotoApiError } from '../orboto-client.js';
8
8
  import { resolveDocId } from './docs.js';
9
- import { resolveProjectByKey } from './shared.js';
9
+ import { resolveProjectByKey, SEMANTIC_DEGRADED_LINE } from './shared.js';
10
10
  /** Turn a 409 from POST /docs/:id/edits into a clear, non-throwing tool
11
11
  * result the model can act on. Returns null for any non-409 so the caller
12
12
  * rethrows (a genuine transport/permission failure should still surface). */
@@ -100,10 +100,11 @@ export function makeSearchDocsHandler(client) {
100
100
  if (input.limit)
101
101
  qs.set('limit', String(input.limit));
102
102
  const res = await client.get(`/docs/search?${qs.toString()}`);
103
+ const degradedNote = res.semanticDegraded ? `(${SEMANTIC_DEGRADED_LINE})\n` : '';
103
104
  if (res.items.length === 0) {
104
105
  return {
105
- content: [{ type: 'text', text: `No docs matched "${input.q}".` }],
106
- structuredContent: { items: [], total: 0 },
106
+ content: [{ type: 'text', text: `${degradedNote}No docs matched "${input.q}".` }],
107
+ structuredContent: { items: [], total: 0, semanticDegraded: res.semanticDegraded ?? false },
107
108
  };
108
109
  }
109
110
  const lines = res.items.map((h) => {
@@ -112,7 +113,7 @@ export function makeSearchDocsHandler(client) {
112
113
  return `- ${h.title} (${h.docKey ?? h.id})${anchor}${where}\n ${h.snippet.replace(/\s+/g, ' ').trim()}`;
113
114
  });
114
115
  return {
115
- content: [{ type: 'text', text: lines.join('\n') }],
116
+ content: [{ type: 'text', text: degradedNote + lines.join('\n') }],
116
117
  structuredContent: {
117
118
  items: res.items.map((h) => ({
118
119
  id: h.id,
@@ -129,6 +130,7 @@ export function makeSearchDocsHandler(client) {
129
130
  url: h.url,
130
131
  })),
131
132
  total: res.total,
133
+ semanticDegraded: res.semanticDegraded ?? false,
132
134
  },
133
135
  };
134
136
  };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ORB-2468 - the skill wrapper's `doc edit` appends content unless --replace,
3
+ * a replace prints the replaced revision and restore command, `doc restore`
4
+ * posts the revision restore.
5
+ */
6
+ import { expect, it } from 'vitest';
7
+ import { cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
8
+ import { createServer } from 'node:http';
9
+ import { spawn } from 'node:child_process';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ const DOC = 'd0c00000-2222-4222-8222-222222222222';
13
+ const REV = 'bbbbbbbb-0000-4000-8000-000000000000';
14
+ it('doc edit appends by default, --replace names the replaced revision, doc restore posts the restore', async () => {
15
+ const dir = await mkdtemp(join(tmpdir(), 'orboto-2468-wrapper-'));
16
+ const requests = [];
17
+ const server = createServer(async (req, res) => {
18
+ let raw = '';
19
+ for await (const chunk of req)
20
+ raw += String(chunk);
21
+ const url = req.url ?? '';
22
+ requests.push({ method: req.method ?? '', url, body: raw ? JSON.parse(raw) : {} });
23
+ res.setHeader('Content-Type', 'application/json');
24
+ const row = { id: DOC, docKey: 'ORB-D7', title: 'Runbook', webUrl: 'https://orboto.example/docs/ORB-D7' };
25
+ if (req.method === 'POST' && url === '/docs/ORB-D7/append-section')
26
+ res.end(JSON.stringify({ appended: true }));
27
+ else if (req.method === 'PATCH' && url === '/docs/ORB-D7')
28
+ res.end(JSON.stringify({ ...row, replaced: { docId: DOC, revisionId: REV, restoreCommand: `orboto doc restore ORB-D7 ${REV}` } }));
29
+ else if (req.method === 'POST' && url === `/docs/ORB-D7/revisions/${REV}/restore`)
30
+ res.end(JSON.stringify(row));
31
+ else {
32
+ res.statusCode = 404;
33
+ res.end(JSON.stringify({ error: `unexpected ${req.method} ${url}` }));
34
+ }
35
+ });
36
+ try {
37
+ await mkdir(join(dir, 'skills/orboto'), { recursive: true });
38
+ await cp(new URL('../../../../skills/orboto/scripts', import.meta.url), join(dir, 'skills/orboto/scripts'), { recursive: true });
39
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
40
+ const address = server.address();
41
+ if (!address || typeof address === 'string')
42
+ throw new Error('Fixture address missing');
43
+ const origin = `http://127.0.0.1:${address.port}`;
44
+ const guard = join(dir, 'network.mjs');
45
+ await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused nonlocal request'); return original(input, init); };`);
46
+ const run = (args) => new Promise((ok, no) => {
47
+ const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
48
+ cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: origin, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
49
+ timeout: 20_000,
50
+ });
51
+ let out = '', err = '';
52
+ child.stdout.on('data', (chunk) => { out += chunk.toString(); });
53
+ child.stderr.on('data', (chunk) => { err += chunk.toString(); });
54
+ child.on('error', no);
55
+ child.on('close', (code) => ok({ code, out, err }));
56
+ child.stdin.end('');
57
+ });
58
+ const appended = await run(['doc', 'edit', 'ORB-D7', '--content', 'new fact']);
59
+ expect(appended.code, appended.err).toBe(0);
60
+ expect(requests.at(-1)).toMatchObject({ method: 'POST', url: '/docs/ORB-D7/append-section', body: { content: 'new fact' } });
61
+ expect(requests.some((r) => r.method === 'PATCH')).toBe(false);
62
+ expect(appended.err).toContain('Appended to doc ORB-D7');
63
+ const replaced = await run(['doc', 'edit', 'ORB-D7', '--replace', '--content', 'whole new body']);
64
+ expect(replaced.code, replaced.err).toBe(0);
65
+ expect(requests.at(-1)).toMatchObject({ method: 'PATCH', url: '/docs/ORB-D7', body: { content: 'whole new body', replace: true } });
66
+ expect(replaced.err).toContain(REV);
67
+ expect(replaced.err).toContain(`orboto doc restore ORB-D7 ${REV}`);
68
+ const refused = await run(['doc', 'edit', 'ORB-D7', '--replace', '--old', 'a', '--new', 'b']);
69
+ expect(refused.code).toBe(2);
70
+ const restored = await run(['doc', 'restore', 'ORB-D7', REV]);
71
+ expect(restored.code, restored.err).toBe(0);
72
+ expect(requests.at(-1)).toMatchObject({ method: 'POST', url: `/docs/ORB-D7/revisions/${REV}/restore` });
73
+ expect(restored.err).toContain('Restored doc ORB-D7');
74
+ }
75
+ finally {
76
+ server.closeAllConnections();
77
+ if (server.listening)
78
+ await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
79
+ await rm(dir, { recursive: true, force: true });
80
+ }
81
+ }, 60_000);
@@ -2,6 +2,7 @@
2
2
  * ORB-799 - docs-AI surface (ask + ingest).
3
3
  */
4
4
  import { z } from 'zod';
5
+ import { SEMANTIC_DEGRADED_LINE } from './shared.js';
5
6
  export const askDocsToolConfig = {
6
7
  title: 'Ask a question against the wiki (RAG)',
7
8
  description: 'Run a natural-language question against the wiki via the workspace\'s RAG pipeline. Returns an `answer` (Markdown) + `citations` array `[{index, title, link, spaceName}]`. The model is the workspace\'s configured AI provider; retrieval requires the embedding provider too - call `orboto_ai_status` to verify both before relying on this. Restrict to a single doc space with `spaceId` when scoping a query (e.g. "only the runbooks space"). Latency is dominated by retrieval + LLM round-trip; expect 2-10s.',
@@ -29,6 +30,8 @@ export function makeAskDocsHandler(client) {
29
30
  if (res.abstained)
30
31
  lines.push('', '(abstained: no claim in the answer could be tied to a retrieved doc - treat as "not in the docs", do not guess)');
31
32
  lines.push('', `(retrieval mode: ${res.mode})`);
33
+ if (res.semanticDegraded)
34
+ lines.push(`(${SEMANTIC_DEGRADED_LINE})`);
32
35
  return {
33
36
  content: [{ type: 'text', text: lines.join('\n') }],
34
37
  structuredContent: {
@@ -36,6 +39,7 @@ export function makeAskDocsHandler(client) {
36
39
  citations: res.citations,
37
40
  mode: res.mode,
38
41
  abstained: res.abstained ?? false,
42
+ semanticDegraded: res.semanticDegraded ?? false,
39
43
  },
40
44
  };
41
45
  };