@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ORB-2502 - orboto_messages lists the newest 10 unread compactly with the exact count; all:true keeps every message in full.
2
+ * ORB-2502 - orboto_messages lists the newest 4 unread compactly (10 before ORB-2548) with the exact count; all:true keeps every message in full.
3
3
  */
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
5
  import { OrbotoClient } from '../orboto-client.js';
@@ -23,11 +23,11 @@ function stubInbox(urls) {
23
23
  });
24
24
  }
25
25
  describe('ORB-2502 - the compact inbox on MCP', () => {
26
- it('asks for the newest 10 compactly, prints the excerpt and the count, and leaves the payload out of the structured answer', async () => {
26
+ it('asks for the newest 4 compactly, prints the excerpt and the count, and leaves the payload out of the structured answer', async () => {
27
27
  const urls = [];
28
28
  stubInbox(urls);
29
29
  const result = await makeAgentMessagesHandler(client)({ since: '2026-09-30T00:00:00Z' }, { sessionId: 'abc' });
30
- expect(urls[0]).toContain('limit=10');
30
+ expect(urls[0]).toContain('limit=4');
31
31
  expect(urls[0]).toContain('compact=true');
32
32
  expect(urls[0]).toContain('since=2026-09-30T00%3A00%3A00Z');
33
33
  const text = result.content[0].text;
@@ -22,10 +22,12 @@ export function repeatMarks(m) {
22
22
  ].filter(Boolean);
23
23
  return marks.length ? ` [${marks.join(', ')}]` : '';
24
24
  }
25
+ /** ORB-2548 - the compact page an MCP inbox read returns by default, sized so it fits the 4,000-character budget. */
26
+ export const COMPACT_PAGE_LIMIT = 4;
25
27
  const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
26
28
  export const agentMessagesToolConfig = {
27
29
  title: 'Agent inbox and message work',
28
- 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).',
30
+ 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 4 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. Server-side filters (ORB-2548): direction sent (only your sends, any read state), threadId (that message and its answers), kind; nextCursor continues the page (cursor). 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).',
29
31
  inputSchema: z.object({
30
32
  messageWork: z.record(z.unknown()).optional()
31
33
  .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}.'),
@@ -34,6 +36,10 @@ export const agentMessagesToolConfig = {
34
36
  since: z.string().optional(),
35
37
  project: z.string().min(1).max(64).optional().describe('Key/UUID; includes unscoped mail.'),
36
38
  includeOwnSends: z.boolean().default(false),
39
+ direction: z.enum(['received', 'sent']).optional(),
40
+ threadId: z.string().uuid().optional(),
41
+ kind: z.enum(['request', 'error', 'info', 'complete']).optional(),
42
+ cursor: z.string().max(500).optional(),
37
43
  ackIds: z.array(z.string().uuid()).max(200).optional(),
38
44
  dismiss: z.object({
39
45
  ids: z.array(z.string().uuid()).min(1).max(200),
@@ -66,6 +72,7 @@ export const agentMessagesToolConfig = {
66
72
  acked: z.number().int(),
67
73
  dismissed: z.number().int().optional(),
68
74
  closed: z.number().int().optional(),
75
+ nextCursor: z.string().nullable().optional(),
69
76
  }).shape,
70
77
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
71
78
  };
@@ -116,28 +123,40 @@ export function makeAgentMessagesHandler(client) {
116
123
  const q = new URLSearchParams();
117
124
  if (args.all)
118
125
  q.set('all', 'true');
119
- q.set('limit', String(args.limit ?? (compact ? 10 : 50)));
126
+ q.set('limit', String(args.limit ?? (compact ? COMPACT_PAGE_LIMIT : 50)));
120
127
  if (compact)
121
128
  q.set('compact', 'true');
122
129
  if (args.since)
123
130
  q.set('since', args.since);
124
131
  if (args.project)
125
132
  q.set('project', args.project);
133
+ if (args.direction)
134
+ q.set('direction', args.direction);
135
+ if (args.threadId)
136
+ q.set('threadId', args.threadId);
137
+ if (args.kind)
138
+ q.set('kind', args.kind);
139
+ if (args.cursor)
140
+ q.set('cursor', args.cursor);
126
141
  const instanceToken = mcpInstanceToken(undefined, extra);
127
142
  if (args.includeOwnSends)
128
143
  q.set('includeOwn', 'true');
129
- else
144
+ else if (args.direction !== 'sent')
130
145
  q.set('excludeRef', instanceToken);
131
- const { messages, sessionId, counts } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
146
+ const { messages, sessionId, counts, nextCursor } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
132
147
  const header = (m) => `${m.own ? `[sent] ${sentState(m)} ` : ''}[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''}${repeatMarks(m)} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})`;
133
148
  const lines = messages.length === 0
134
149
  ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
135
150
  : messages.flatMap((m) => (compact ? compactLines(m, header(m)) : [`${header(m)}${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`]));
151
+ if (messages.length === 0 && args.direction === 'sent')
152
+ lines[0] = 'Nothing sent matches.';
136
153
  if (messages.length > 0) {
137
154
  lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId (toSessionRef = the sender's instance short id reaches that session only).`);
138
155
  }
139
156
  if (compact && counts)
140
157
  lines.push(`${counts.unread} unread, ${counts.olderThan7Days} older than 7 days (all, limit N, since)`);
158
+ if (nextCursor)
159
+ lines.push(`More messages: call again with cursor "${nextCursor}".`);
141
160
  if (args.dismiss) {
142
161
  lines.unshift(`Dismissed ${dismissedCount} message(s) as ${args.dismiss.reason}${closedCount > 0 ? `, ${closedCount} closed for the account` : ''}${feedbackSent > 0 ? `, ${feedbackSent} sender(s) told about the misroute` : ''}.`);
143
162
  }
@@ -160,6 +179,7 @@ export function makeAgentMessagesHandler(client) {
160
179
  acked,
161
180
  dismissed: dismissedCount,
162
181
  closed: closedCount,
182
+ nextCursor: nextCursor ?? null,
163
183
  },
164
184
  };
165
185
  };
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { AgentInventoryKindSchema, AgentSessionWakeSchema } from './agent-presence-schema.js';
3
+ import { AgentSessionScopeSchema } from './agent-session-scope.js';
4
+ /** ORB-2548 - one presence row as orboto_agent_presence returns it; the full inventory row is `orboto agents --json` and `GET /v1/agent/who?include=inventory`. */
5
+ export const AgentPresenceRowSchema = z.object({
6
+ sessionId: z.string().uuid(),
7
+ userEmail: z.string(),
8
+ userFullName: z.string().nullable(),
9
+ kind: AgentInventoryKindSchema,
10
+ owner: z.string().nullable(),
11
+ status: z.string(),
12
+ lane: z.string().nullable(),
13
+ scope: AgentSessionScopeSchema.nullable(),
14
+ memberProjects: z.number().int(),
15
+ connections: z.array(z.string()),
16
+ wake: AgentSessionWakeSchema.optional(),
17
+ state: z.string().optional(),
18
+ origin: z.object({ host: z.string().nullable(), repo: z.string().nullable(), pid: z.number().nullable(), harness: z.string().nullable(), client: z.string().nullable() }).nullable(),
19
+ workingOn: z.string().nullable(),
20
+ lastSeenAt: z.string(),
21
+ redacted: z.boolean().optional(),
22
+ });
@@ -1,19 +1,25 @@
1
1
  /** ORB-2264 - the live directory: who a sender can address before sending. */
2
2
  import { z } from 'zod';
3
- import { AgentSessionRoleSchema } from './agent-session-scope.js';
4
3
  import { ownerLine } from './responsibilities.js';
5
- import { originText } from './agent-coordination.js';
4
+ import { nextPageLine, originText, PresenceFilterShape, presenceQuery, readPresence, unknownProjectText } from './agent-coordination.js';
6
5
  import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
6
+ export function refusalLines(refusals) {
7
+ if (!refusals?.length)
8
+ return [];
9
+ return ['Refused heartbeats in the last 24 hours (no role address, not listed):', ...refusals.map((r) => {
10
+ const where = [r.lastHost, r.lastRepo].filter(Boolean).join('/') || 'unknown origin';
11
+ return `- ${r.email}: ${r.count} (no scope ${r.scopeRequired}, second start ${r.sessionExists}), last ${r.lastAt} from ${where}${r.lastHarness ? ` (${r.lastHarness})` : ''}`;
12
+ })];
13
+ }
7
14
  export const agentWhoToolConfig = {
8
15
  title: 'Who receives agent mail',
9
- 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).',
16
+ description: 'Live agent sessions and the role addresses (integrator@KEY) they answer to. GET /v1/agent/who; 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. A session without a declared scope is not listed; the refused heartbeats of the last 24 hours follow per account (refusals). `for` (free text) names the topic owner and keeps its sessions; `state` keeps one state (orphaned: only another process touches it); email, sessionId (id or 8+ character prefix), kind and wake narrow on the server; 4 rows a page, nextCursor continues (cursor); a project that is unknown or not visible to you answers so in one line.',
10
17
  inputSchema: z.object({
11
18
  project: z.string().min(1).max(64).optional(),
12
- role: AgentSessionRoleSchema.optional(),
13
19
  for: z.string().min(2).max(2000).optional(),
14
- state: z.enum(['live', 'quiet', 'orphaned', 'unknown_origin']).optional(),
20
+ ...PresenceFilterShape,
15
21
  }).shape,
16
- outputSchema: z.object({ sessions: z.array(z.unknown()), owner: z.unknown().optional() }).shape,
22
+ outputSchema: z.object({ sessions: z.array(z.unknown()), owner: z.unknown().optional(), nextCursor: z.string().nullable(), refusals: z.array(z.unknown()).optional() }).shape,
17
23
  annotations: { readOnlyHint: true, idempotentHint: true },
18
24
  };
19
25
  /** One directory row as a line: address, account, session, projects, channel, ticket. */
@@ -28,25 +34,19 @@ export function whoLine(s) {
28
34
  }
29
35
  export function makeAgentWhoHandler(client) {
30
36
  return async (args = {}) => {
31
- const qs = new URLSearchParams();
32
- if (args.state)
33
- qs.set('state', args.state);
34
- if (args.project)
35
- qs.set('project', args.project);
36
- if (args.role)
37
- qs.set('role', args.role);
38
- if (args.for)
39
- qs.set('for', args.for);
40
- const res = await client.get(`/v1/agent/who${qs.size ? `?${qs.toString()}` : ''}`);
37
+ const res = await readPresence(client, `/v1/agent/who?${presenceQuery(args)}`, args.project);
38
+ if (!res)
39
+ return { content: [{ type: 'text', text: unknownProjectText(args.project) }], structuredContent: { sessions: [], nextCursor: null } };
41
40
  const ownerLines = args.for
42
41
  ? [res.owner ? `Owner:\n${ownerLine(res.owner)}` : `No entry in the responsibility map matches "${args.for}".`]
43
42
  : [];
44
43
  const lines = res.sessions.length
45
- ? [...ownerLines, `${res.sessions.length} live session(s):`, ...res.sessions.map(whoLine)]
44
+ ? [...ownerLines, `${res.sessions.length} live session(s)${res.nextCursor ? ' on this page' : ''}:`, ...res.sessions.map(whoLine), ...nextPageLine(res.nextCursor)]
46
45
  : [...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.'];
46
+ lines.push(...refusalLines(res.refusals));
47
47
  return {
48
48
  content: [{ type: 'text', text: lines.join('\n') }],
49
- structuredContent: { sessions: res.sessions, ...(args.for ? { owner: (res.owner ?? null) } : {}) },
49
+ structuredContent: { sessions: res.sessions, ...(args.for ? { owner: (res.owner ?? null) } : {}), nextCursor: res.nextCursor ?? null, ...(res.refusals?.length ? { refusals: res.refusals } : {}) },
50
50
  };
51
51
  };
52
52
  }
@@ -17,10 +17,17 @@ describe('orboto_who', () => {
17
17
  return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [entry] }) };
18
18
  });
19
19
  const result = await makeAgentWhoHandler(client)({ project: 'ORB', role: 'integrator' });
20
- expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?project=ORB&role=integrator');
20
+ expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?project=ORB&role=integrator&limit=4');
21
21
  expect(result.content[0].text).toContain('integrator@ORB - claude@orboto.io session f072e1c5, working, claude-code online');
22
22
  expect(result.structuredContent).toMatchObject({ sessions: [expect.objectContaining({ shortId: 'f072e1c5' })] });
23
23
  });
24
+ it('ORB-2548: a project that is unknown or not visible answers in one line instead of a raw 404', async () => {
25
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: false, status: 404, statusText: 'Not Found', headers: new Headers(), text: async () => '{"errorKey":"errors.agents.message_project_not_found"}', json: async () => ({}) }));
26
+ const result = await makeAgentWhoHandler(client)({ project: 'gone' });
27
+ expect(result.isError).toBeUndefined();
28
+ expect(result.content[0].text).toBe('Project "GONE" is unknown or not visible to you - no session is listed for it.');
29
+ expect(result.structuredContent).toEqual({ sessions: [], nextCursor: null });
30
+ });
24
31
  it('says a request would be refused when nobody matches, and is read-only', async () => {
25
32
  vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({ ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) }));
26
33
  const result = await makeAgentWhoHandler(client)({});
@@ -41,6 +48,6 @@ describe('orboto_who', () => {
41
48
  return { ok: true, status: 200, statusText: 'OK', text: async () => '', json: async () => ({ sessions: [] }) };
42
49
  });
43
50
  await makeAgentWhoHandler(client)({ state: 'unknown_origin' });
44
- expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?state=unknown_origin');
51
+ expect(urls[0]).toBe('https://orboto.example.com/v1/agent/who?state=unknown_origin&limit=4');
45
52
  });
46
53
  });
@@ -15,12 +15,12 @@ const CATEGORY_TO_LEGACY = {
15
15
  wont_fix: 'WONT_FIX',
16
16
  };
17
17
  const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200).optional()
18
- .describe('Ticket keys, e.g. ["ACME-1"]; at most 200. Give ticketKeys or query.');
18
+ .describe('Ticket keys, at most 200; or give query.');
19
19
  /** ORB-2054 - the target set can be an OQL query instead of a key list. */
20
20
  const QUERY_SOURCE = z.string().min(1).max(8000).optional()
21
- .describe('OQL selecting the tickets instead of ticketKeys (orboto_query grammar, your ACL); at most 200 matches.');
21
+ .describe('OQL instead of ticketKeys (your ACL), at most 200 matches.');
22
22
  const QUERY_PROJECT = z.string().optional()
23
- .describe('Project key that scopes `query` (prepended as project = KEY).');
23
+ .describe('Project key scoping `query`.');
24
24
  /** Matches per call the MCP loop will process; above that the server-side bulk route is the right tool. */
25
25
  export const BULK_QUERY_MAX = 200;
26
26
  /** Resolve `query` to ticket keys through POST /query, paging until the cap. */
@@ -74,7 +74,7 @@ function makeToggleHandler(client, completed) {
74
74
  const toggleInputSchema = z.object({
75
75
  ticketKey: z.string().min(3),
76
76
  item: z.union([
77
- z.number().int().min(1).describe('1-based index, global across all checklists on this ticket'),
77
+ z.number().int().min(1).describe('1-based index across all checklists'),
78
78
  z.string().describe('Item UUID - for callers that already have it'),
79
79
  ]),
80
80
  }).shape;
@@ -169,7 +169,7 @@ export const updateCheckToolConfig = {
169
169
  inputSchema: z.object({
170
170
  ticketKey: z.string().min(3),
171
171
  item: z.union([
172
- z.number().int().min(1).describe('1-based index, global across all checklists on this ticket'),
172
+ z.number().int().min(1).describe('1-based index across all checklists'),
173
173
  z.string().describe('Item UUID - for callers that already have it'),
174
174
  ]),
175
175
  content: z.string().min(1).max(2000).optional().describe('New item text.'),
@@ -16,7 +16,7 @@ const CATEGORY_TO_LEGACY = {
16
16
  };
17
17
  export const claimToolConfig = {
18
18
  title: 'Claim a ticket (assign self + in_progress + timer)',
19
- description: 'Assign yourself, move to in_progress and start a timer. Repeated claims recheck the specification gate. Worker bots need ready, or override=true with a reason; enabled epics are blocked. sole removes other assignees after the gate passes. force permits reopening done tickets. noTimer skips time tracking. On human accounts a different active timer is stopped first and its elapsed time booked to that ticket. Bot timers are per instance and never auto-stop each other: agentSessionToken defaults to this MCP connection; own both start and stop.',
19
+ description: 'Assign yourself, move to in_progress and start a timer. Repeated claims recheck the specification gate. Worker bots need ready, or override=true with a reason; enabled epics are blocked. sole removes other assignees after the gate passes. force permits reopening done tickets. noTimer skips time tracking. On human accounts a different active timer is stopped first and its elapsed time booked to that ticket. Bot timers are per instance and never auto-stop each other: agentSessionToken defaults to this MCP connection; own both start and stop. The server attributes the timer to the session of this connection only (ORB-2550): a token that is not its own is refused 403, a session another harness process owns 409.',
20
20
  inputSchema: z.object({
21
21
  ticketKey: z.string().min(3),
22
22
  override: z.boolean().optional().describe("Spec override; reason required."),
@@ -24,7 +24,7 @@ export const claimToolConfig = {
24
24
  sole: z.boolean().optional().describe('Remove other assignees after gate.'),
25
25
  force: z.boolean().optional().describe('Reopen done.'),
26
26
  noTimer: z.boolean().optional().describe('No timer.'),
27
- agentSessionToken: z.string().optional().describe('Instance token for bot timers.'),
27
+ agentSessionToken: z.string().optional().describe('Instance token for bot timers; must be this connection\'s own.'),
28
28
  }).shape,
29
29
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
30
30
  };
@@ -39,11 +39,11 @@ export const uploadDocAttachmentToolConfig = {
39
39
  title: 'Upload an attachment to a doc page',
40
40
  description: 'Upload a file as a doc attachment: returns its Markdown image (or link) line + a stable download URL. Bytes come in as base64 (`contentBase64`) so this works without local FS access on the agent side. Set `embed=true` to also PATCH the doc body to append the Markdown line - useful for dropping a screenshot into a page in one call. MIME / extension policy is enforced server-side: blocked types (HTML, SVG, executables) surface a 415.',
41
41
  inputSchema: z.object({
42
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
42
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
43
43
  filename: z.string().min(1).describe('Display filename, e.g. "architecture-diagram.png".'),
44
44
  contentBase64: z.string().min(1).describe('File content, base64-encoded.'),
45
45
  altText: z.string().optional().describe('Alt-text for the Markdown image line. Defaults to filename.'),
46
- embed: z.boolean().optional().describe('If true, PATCHes the doc body to append the Markdown line. Idempotent up to duplicate detection - re-running appends another copy.'),
46
+ embed: z.boolean().optional().describe('Append the Markdown line to the doc body; a re-run appends another copy.'),
47
47
  }).shape,
48
48
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
49
49
  };
@@ -100,7 +100,7 @@ export const listDocAttachmentsToolConfig = {
100
100
  title: 'List attachments on a doc page',
101
101
  description: 'Return the doc\'s attachments newest-first, with stable download URLs the agent can hand to the user. Empty list = no attachments.',
102
102
  inputSchema: z.object({
103
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
103
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
104
104
  }).shape,
105
105
  annotations: { readOnlyHint: true, idempotentHint: true },
106
106
  };
@@ -139,7 +139,7 @@ export const deleteDocAttachmentToolConfig = {
139
139
  title: 'Delete an attachment from a doc page',
140
140
  description: 'DESTRUCTIVE - drops the file row + deletes the underlying S3 object. Does NOT rewrite the doc body, so embedded Markdown lines pointing at the deleted URL will surface as broken images / links. Walk those by hand if needed.',
141
141
  inputSchema: z.object({
142
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
142
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
143
143
  attachmentId: z.string().uuid(),
144
144
  }).shape,
145
145
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
@@ -7,7 +7,7 @@ export const listDocCommentsToolConfig = {
7
7
  title: 'List comments on a doc page',
8
8
  description: 'Return comments on a doc page, oldest-first so the reply tree reads top-to-bottom. Each row carries the author name + content + resolved state. Cursor-paged - pass `cursor` from the previous call\'s nextCursor to walk older pages.',
9
9
  inputSchema: z.object({
10
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
10
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
11
11
  limit: z.number().int().min(1).max(100).optional(),
12
12
  cursor: z.string().optional(),
13
13
  }).shape,
@@ -84,7 +84,7 @@ export const postDocCommentToolConfig = {
84
84
  title: 'Post a comment on a doc page (or reply to one)',
85
85
  description: 'Add a comment to a doc page. Pass `parentCommentId` to reply (the API flattens reply chains past one level so a reply-of-reply lands as a sibling of the original reply). Optional `anchor` (`{text, before, after}`) attaches the comment to a specific highlight in the body - the frontend uses the surrounding context to re-locate the anchor even after the doc has been edited. Mention a person with the markup `@[Full Name](user:<uuid>)` - the UUIDs come from `orboto_list_users`. The response reports `mentions` (tokens that resolved to a user, who is then notified) and `unresolvedMentions` (tokens that named nobody); a mention is never silently dropped.',
86
86
  inputSchema: z.object({
87
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
87
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
88
88
  content: z.string().min(1).max(4000),
89
89
  parentCommentId: z.string().uuid().optional().describe('Reply to an existing comment.'),
90
90
  anchor: z.object({
@@ -136,7 +136,7 @@ export const resolveDocCommentToolConfig = {
136
136
  title: 'Mark a doc comment thread as resolved (or reopen it)',
137
137
  description: 'Toggle a comment thread\'s resolved state. Pass `resolved=true` to fold the conversation, `resolved=false` to reopen. Always acts on the thread ROOT - resolving from a reply folds the whole conversation, same as the web UI.',
138
138
  inputSchema: z.object({
139
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
139
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
140
140
  commentId: z.string().uuid(),
141
141
  resolved: z.boolean(),
142
142
  }).shape,
@@ -163,7 +163,7 @@ export const updateDocCommentToolConfig = {
163
163
  title: 'Edit your own doc comment',
164
164
  description: 'Edit a doc comment\'s body. Author OR super-admin can edit; everyone else gets a 403. Doc-comments do not carry a revision history today (unlike ticket comments), so the prior content is overwritten in place. A no-op call (same content as before) returns the unchanged row.',
165
165
  inputSchema: z.object({
166
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
166
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
167
167
  commentId: z.string().uuid(),
168
168
  content: z.string().min(1).max(4000),
169
169
  }).shape,
@@ -187,7 +187,7 @@ export const deleteDocCommentToolConfig = {
187
187
  title: 'Delete a doc comment',
188
188
  description: 'DESTRUCTIVE - drops the comment + (via FK cascade) every reply under it. Only the author can delete their own comments; super-admins can delete anyone\'s. Returns success silently; 403 surfaces as an OrbotoApiError.',
189
189
  inputSchema: z.object({
190
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
190
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
191
191
  commentId: z.string().uuid(),
192
192
  }).shape,
193
193
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
@@ -7,7 +7,7 @@ export const exportDocMdToolConfig = {
7
7
  title: 'Export a doc page as Markdown',
8
8
  description: 'Return the doc body as plain Markdown (text/markdown). Differs from orboto_get_doc.content in that the server-side export endpoint emits the canonical saved body - agents pulling docs to feed into another tool should prefer this over get_doc, which wraps the body in get-doc envelope text + backlinks.',
9
9
  inputSchema: z.object({
10
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
10
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
11
11
  }).shape,
12
12
  annotations: { readOnlyHint: true, idempotentHint: true },
13
13
  };
@@ -27,9 +27,9 @@ export function makeExportDocMdHandler(client) {
27
27
  }
28
28
  export const exportDocPdfToolConfig = {
29
29
  title: 'Export a doc page as PDF',
30
- description: 'Render the doc page to PDF (Puppeteer-backed PdfService) and return the bytes as a base64 MCP resource attachment. Requires the PDF engine to be configured - deployments without Chromium / Puppeteer return 503 (errors.pdf.engine_unavailable) which surfaces as an OrbotoApiError.',
30
+ description: 'Render the doc page to PDF and return it as a base64 MCP resource attachment. Requires the PDF engine to be configured - deployments without Chromium / Puppeteer return 503 (errors.pdf.engine_unavailable) which surfaces as an OrbotoApiError.',
31
31
  inputSchema: z.object({
32
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
32
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
33
33
  }).shape,
34
34
  annotations: { readOnlyHint: true, idempotentHint: true },
35
35
  };
@@ -7,7 +7,7 @@ export const listDocRevisionsToolConfig = {
7
7
  title: 'List the revision history of a doc page',
8
8
  description: 'Return saved revisions for a doc page, newest-first. Each row carries the revision id, title at snapshot time, editor user id, and editedAt timestamp; the body content is NOT in this list (use orboto_get_doc_revision to fetch one). Cursor-paged - pass `cursor` to walk older pages.',
9
9
  inputSchema: z.object({
10
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
10
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
11
11
  limit: z.number().int().min(1).max(100).optional().describe('Page size. Defaults to API default (25).'),
12
12
  cursor: z.string().optional().describe('Opaque cursor from a previous call\'s nextCursor.'),
13
13
  }).shape,
@@ -56,7 +56,7 @@ export const getDocRevisionToolConfig = {
56
56
  title: 'Get a single doc revision',
57
57
  description: 'Return the full content + title of one saved revision. Use this to diff against the current doc before deciding whether to restore.',
58
58
  inputSchema: z.object({
59
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
59
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
60
60
  revisionId: z.string().uuid(),
61
61
  }).shape,
62
62
  annotations: { readOnlyHint: true, idempotentHint: true },
@@ -85,7 +85,7 @@ export const restoreDocRevisionToolConfig = {
85
85
  title: 'Restore a doc page to a saved revision',
86
86
  description: 'Roll back the doc to the saved revision. The API snapshots the CURRENT body to a fresh revision row first, so the restore itself is also undoable. Returns the post-restore doc state.',
87
87
  inputSchema: z.object({
88
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
88
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
89
89
  revisionId: z.string().uuid(),
90
90
  }).shape,
91
91
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
@@ -152,7 +152,7 @@ export const updateDocSpaceToolConfig = {
152
152
  description: z.string().nullish(),
153
153
  icon: z.string().nullish(),
154
154
  isPublic: z.boolean().optional().describe('DEPRECATED - use guestsVisible.'),
155
- visibility: z.enum(['workspace', 'project_members', 'restricted']).optional().describe('Space-level read gate: workspace = every internal member, project_members = project members whose role grants doc:view (project spaces only), restricted = standalone allowlist. Requires manage rights.'),
155
+ visibility: z.enum(['workspace', 'project_members', 'restricted']).optional().describe('Read gate: workspace = every internal member, project_members = members with doc:view (project spaces), restricted = allowlist. Needs manage rights.'),
156
156
  guestsVisible: z.boolean().optional().describe('Explicit guest (external user) opt-in. Requires manage rights.'),
157
157
  memberIds: z.array(z.string().uuid()).optional().describe('Replaces the restricted-space allowlist. Requires manage rights.'),
158
158
  slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
@@ -276,7 +276,7 @@ export const createDocToolConfig = {
276
276
  inputSchema: z.object({
277
277
  spaceId: z.string().min(1).describe('Target doc space - key (e.g. ORB-S1), name, or UUID. Discover via orboto_list_doc_spaces.'),
278
278
  title: z.string().min(1).max(255),
279
- content: z.string().optional().describe('Markdown body (CommonMark + GFM). Empty or omitted: a blank page. Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown = neutral, label <= 24 chars).'),
279
+ content: z.string().optional().describe('Markdown (GFM); omitted: a blank page. Pills: `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown = neutral, label <= 24 chars).'),
280
280
  parentDocId: z.string().uuid().nullable().optional().describe('Nest under another doc.'),
281
281
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional().describe('Defaults to "workspace".'),
282
282
  icon: z.string().nullish().describe('Single emoji shown in the tree.'),
@@ -313,9 +313,9 @@ export const updateDocToolConfig = {
313
313
  title: 'Update a doc page',
314
314
  description: 'Patch a doc; content is appended unless replace. ORB-2468: title, visibility, parent and icon are set as given; content goes in as a new section (identical content twice is a no-op); replace: true replaces the whole page and the answer names the revision holding the previous body plus its restore command. Every body or title change snapshots a revision; orboto_restore_doc_revision rolls back. For one passage or section use orboto_edit_doc.',
315
315
  inputSchema: z.object({
316
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
316
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
317
317
  title: z.string().min(1).max(255).optional(),
318
- content: z.string().optional().describe('Markdown to append (with replace: the whole body). Images are inline elements; status pills use `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown reads as neutral, label <= 24 chars).'),
318
+ content: z.string().optional().describe('Markdown to append (with replace: the whole body). Pills: `:badge[LABEL]{tone=success}` (neutral/info/success/warning/danger/accent, unknown = neutral, label <= 24 chars).'),
319
319
  replace: z.boolean().optional(),
320
320
  parentDocId: z.string().uuid().nullable().optional(),
321
321
  visibility: z.enum(['public', 'workspace', 'members', 'specific']).optional(),
@@ -368,7 +368,7 @@ export const deleteDocToolConfig = {
368
368
  title: 'Delete a doc page',
369
369
  description: 'DESTRUCTIVE - removes the page from the tree. Revisions are NOT auto-cascaded; the API keeps them so an admin could in principle resurrect from history, but the page itself is gone from listings. System-generated primer docs refuse deletion (the regenerator re-creates them). Returns success silently; 404 is also silent (idempotent).',
370
370
  inputSchema: z.object({
371
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
371
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
372
372
  }).shape,
373
373
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
374
374
  };
@@ -386,9 +386,9 @@ export const moveDocToolConfig = {
386
386
  title: 'Move a doc page (reparent / reorder / cross-space)',
387
387
  description: 'Reparent a doc, change its sort order, or move it into a different space. All three target fields are optional but at least one must be supplied. Distinct from orboto_update_doc\'s parentDocId because the API endpoint emits a different WS event for the tree-rebuild path connected clients care about.',
388
388
  inputSchema: z.object({
389
- docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
389
+ docId: z.string().min(1).describe('Doc UUID or key (ORB-D12, DOC-5).'),
390
390
  parentDocId: z.string().uuid().nullable().optional().describe('null = top-level; UUID = nest under that doc.'),
391
- spaceId: z.string().uuid().optional().describe('Move into a different space. Caller must be member of both; the parent must live in the target space, and without parentDocId the doc lands at the top level.'),
391
+ spaceId: z.string().uuid().optional().describe('Target space (member of both); the parent must be in it, else the doc lands at the top level.'),
392
392
  sortOrder: z.number().int().optional(),
393
393
  }).shape,
394
394
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -4,7 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  export const getTimerToolConfig = {
6
6
  title: 'Get current timer',
7
- description: 'Return the caller\'s currently-running stopwatch (or null if no timer is active). The lane is resolved from this session\'s instance token, so a timer started by `orboto_claim` / `orboto_timer_start` in the same checkout reads back (ORB-2137); `laneFallback: true` means the timer was matched as the account\'s only running one because this instance\'s lane was empty - another instance started it.',
7
+ description: 'Return the caller\'s currently-running stopwatch (or null if no timer is active). The lane is resolved from this session\'s instance token, so a timer started by `orboto_claim` / `orboto_timer_start` in the same checkout reads back (ORB-2137); `laneFallback: true` means the timer was matched as the account\'s only running one because this instance\'s lane was empty - another instance started it. The answer names who runs the timer and its agent session (ORB-2550); the fallback never takes the timer of a session another harness process owns (409 `errors.time.timer_of_other_session` on stop).',
8
8
  inputSchema: z.object({}).shape,
9
9
  annotations: { readOnlyHint: true, idempotentHint: true },
10
10
  };
@@ -28,6 +28,7 @@ export function makeGetTimerHandler(client) {
28
28
  `Timer ${isPaused ? 'paused' : 'running'} on ${ticketLabel}`,
29
29
  `Elapsed: ${minutes} min (${totalSeconds}s total)`,
30
30
  `Started: ${timer.startedAt}`,
31
+ `Owner: ${timer.userFullName ?? timer.userEmail ?? timer.userId}${timer.agentSessionLabel ? `, session ${timer.agentSessionLabel}` : ', no agent session'}`,
31
32
  isPaused ? `Paused: ${timer.pausedAt}` : null,
32
33
  timer.laneFallback ? 'Matched via the single-active-timer fallback - another instance started it.' : null,
33
34
  timer.description ? `Note: ${timer.description}` : null,
@@ -46,6 +47,10 @@ export function makeGetTimerHandler(client) {
46
47
  totalSeconds,
47
48
  paused: isPaused,
48
49
  laneFallback: timer.laneFallback === true,
50
+ userId: timer.userId,
51
+ userEmail: timer.userEmail ?? null,
52
+ agentSessionId: timer.agentSessionId ?? null,
53
+ agentSessionLabel: timer.agentSessionLabel ?? null,
49
54
  },
50
55
  },
51
56
  };
@@ -4,7 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  export const whoamiToolConfig = {
6
6
  title: 'Show the authenticated MCP principal',
7
- description: 'Return the authenticated user record `{id, email, fullName, isBot, workspaceLocale}` for the API key this server runs with. Useful for debugging which credential / bot identity is in use when multiple MCP configs are wired to different `orb_*` keys. **`workspaceLocale` (ORB-989)** is the language the workspace expects tickets / comments / docs in - check it before a mass-create so you write in the right language from the start instead of relying on the after-the-fact language-mismatch warning.',
7
+ description: 'Return the authenticated user record `{id, email, fullName, isBot, workspaceLocale}` for the API key this server runs with. Useful for debugging which credential / bot identity is in use when multiple MCP configs are wired to different `orb_*` keys. **`workspaceLocale` (ORB-989)** is the language the workspace expects tickets / comments / docs in - check it before a mass-create so you write in the right language from the start instead of relying on the after-the-fact language-mismatch warning. **`agentSession` (ORB-2550)** is the agent session every write of this connection is attributed to, null when the server knows none for its instance token; `foreign: true` means another harness process owns it, so writes carry no session and timers and work sessions are refused until this process has an instance token (`ORBOTO_AGENT_SESSION`) or a profile of its own.',
8
8
  inputSchema: z.object({}).shape,
9
9
  outputSchema: z.object({
10
10
  id: z.string(),
@@ -13,6 +13,7 @@ export const whoamiToolConfig = {
13
13
  isBot: z.boolean(),
14
14
  workspaceLocale: z.string().nullable(),
15
15
  actingAuthorizedBy: z.object({ id: z.string(), email: z.string(), fullName: z.string() }).nullable(),
16
+ agentSession: z.object({ id: z.string(), label: z.string(), harness: z.string().nullable(), pid: z.number().nullable(), foreign: z.boolean() }).nullable(),
16
17
  }).shape,
17
18
  annotations: { readOnlyHint: true, idempotentHint: true },
18
19
  };
@@ -32,6 +33,13 @@ export function makeWhoamiHandler(client) {
32
33
  if (me.actingAuthorizedBy) {
33
34
  lines.push(` acting as this bot, authorised by: ${me.actingAuthorizedBy.fullName} <${me.actingAuthorizedBy.email}>`);
34
35
  }
36
+ const session = me.agentSession ?? null;
37
+ if (session) {
38
+ lines.push(` session: ${session.id} (${session.label})${session.foreign ? ` - owned by ${session.harness ?? 'unknown'} pid ${session.pid ?? 0}, another process: writes carry no session, timers and work sessions are refused; give this process an instance token of its own (ORBOTO_AGENT_SESSION=<own value>) or its own identity (orboto profile bind <harness> <profile>); after a restart of the same harness, orboto session-start registers the row again` : ''}`);
39
+ }
40
+ else {
41
+ lines.push(' session: none - the server knows no session for this connection\'s instance token yet');
42
+ }
35
43
  return {
36
44
  content: [{ type: 'text', text: lines.join('\n') }],
37
45
  structuredContent: {
@@ -41,6 +49,7 @@ export function makeWhoamiHandler(client) {
41
49
  isBot,
42
50
  workspaceLocale,
43
51
  actingAuthorizedBy: me.actingAuthorizedBy ?? null,
52
+ agentSession: session,
44
53
  },
45
54
  };
46
55
  };
@@ -39,9 +39,20 @@ describe('orboto_whoami', () => {
39
39
  isBot: true,
40
40
  workspaceLocale: 'en',
41
41
  actingAuthorizedBy: null,
42
+ agentSession: null,
42
43
  });
43
44
  expect(res.content[0].text).toContain('agent-e@orboto.io');
44
45
  });
46
+ it('ORB-2550 - names the session the connection writes under, and a session another live process owns', async () => {
47
+ const session = { id: '0b0c3a1e-7f73-4c55-8a4e-1d0f0f0f0f0f', label: 'mcp-stdio#0b0c3a1e', harness: 'claude-code', pid: 4242, foreign: false };
48
+ stub([{ json: { id: 'u5', email: 'agent@orboto.io', isBot: true, agentSession: session } }, { json: { id: 'u5', email: 'agent@orboto.io', isBot: true, agentSession: { ...session, foreign: true } } }]);
49
+ const own = await makeWhoamiHandler(client)();
50
+ expect(own.structuredContent).toMatchObject({ agentSession: session });
51
+ expect(own.content[0].text).toContain(`session: ${session.id} (mcp-stdio#0b0c3a1e)`);
52
+ const foreign = await makeWhoamiHandler(client)();
53
+ expect(foreign.content[0].text).toContain('owned by claude-code pid 4242, another process: writes carry no session, timers and work sessions are refused; give this process an instance token of its own (ORBOTO_AGENT_SESSION=<own value>)');
54
+ expect(foreign.content[0].text).toContain('after a restart of the same harness, orboto session-start registers the row again');
55
+ });
45
56
  it('surfaces workspaceLocale in the text output when present (ORB-989)', async () => {
46
57
  stub([{ json: { id: 'u4', email: 'agent@orboto.io', fullName: 'Agent', isBot: true, workspaceLocale: 'de' } }]);
47
58
  const res = await makeWhoamiHandler(client)();
@@ -11,7 +11,7 @@
11
11
  import { z } from 'zod';
12
12
  export const listGitAppInstallationsToolConfig = {
13
13
  title: 'List GitHub App installations',
14
- description: 'Every GitHub App installation orboto knows (admin:system:read): org/user, install time, suspended state and the project keys allowed to use it.',
14
+ description: 'Every known GitHub App installation (admin:system:read): account, install time, suspension, allowed project keys.',
15
15
  inputSchema: z.object({}).shape,
16
16
  annotations: { readOnlyHint: true, idempotentHint: true },
17
17
  };
@@ -611,4 +611,11 @@ describe('orboto_get_timer', () => {
611
611
  expect(sc.timer.paused).toBe(false);
612
612
  expect(sc.timer.ticketTitle).toBe('Bug');
613
613
  });
614
+ it('ORB-2550 - names who runs the timer and in which agent session', async () => {
615
+ const startedAt = new Date().toISOString();
616
+ stub([{ json: { id: 'tm2', userId: 'u1', ticketId: 't1', startedAt, pausedAt: null, accumulatedSeconds: 0, description: null, userEmail: 'claude@orboto.io', userFullName: 'Claude Agent', agentSessionId: 's-1', agentSessionLabel: 'claude-code#0b0c3a1e' } }]);
617
+ const res = await makeGetTimerHandler(client)();
618
+ expect(res.content[0].text).toContain('Owner: Claude Agent, session claude-code#0b0c3a1e');
619
+ expect(res.structuredContent).toMatchObject({ timer: { userId: 'u1', userEmail: 'claude@orboto.io', agentSessionId: 's-1', agentSessionLabel: 'claude-code#0b0c3a1e' } });
620
+ });
614
621
  });