@orboto/mcp 0.195.1 → 0.197.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 (40) hide show
  1. package/dist/event-bridge.js +5 -0
  2. package/dist/event-bridge.test.js +34 -0
  3. package/dist/http-transport.js +6 -3
  4. package/dist/manifest-size.test.js +4 -2
  5. package/dist/orboto-client.js +1 -1
  6. package/dist/server.js +10 -0
  7. package/dist/tool-docs.js +1 -1
  8. package/dist/tools/agent-coordination.js +17 -23
  9. package/dist/tools/agent-coordination.test.js +40 -6
  10. package/dist/tools/agent-message-work.js +22 -0
  11. package/dist/tools/agent-message-work.test.js +33 -0
  12. package/dist/tools/agent-messages.js +18 -8
  13. package/dist/tools/agent-presence-schema.js +35 -0
  14. package/dist/tools/claim.js +22 -19
  15. package/dist/tools/claim.test.js +7 -5
  16. package/dist/tools/get-ticket.js +3 -0
  17. package/dist/tools/git-rotate-token.js +61 -0
  18. package/dist/tools/git-rotate-token.test.js +87 -0
  19. package/dist/tools/list-git-app-installations.js +1 -10
  20. package/dist/tools/list-tickets.js +4 -0
  21. package/dist/tools/project-readiness-schema.js +16 -0
  22. package/dist/tools/project-readiness.js +16 -0
  23. package/dist/tools/project-readiness.test.js +19 -0
  24. package/dist/tools/raci.js +4 -2
  25. package/dist/tools/sentry.js +85 -0
  26. package/dist/tools/sentry.test.js +107 -0
  27. package/dist/tools/shared.js +9 -0
  28. package/dist/tools/spec-check.js +15 -0
  29. package/dist/tools/spec-check.test.js +23 -0
  30. package/dist/tools/spec-readiness.test.js +85 -0
  31. package/dist/tools/spec-schemas.js +34 -0
  32. package/dist/tools/ticket-writes.js +27 -22
  33. package/dist/tools/ticket-writes.test.js +3 -4
  34. package/dist/tools/update-project.js +19 -12
  35. package/dist/tools/update-project.test.js +13 -4
  36. package/dist/tools/work-next.js +1 -1
  37. package/dist/tools/work-sessions-next.test.js +23 -0
  38. package/dist/tools/work-sessions.js +30 -9
  39. package/dist/tools/work-sessions.test.js +14 -0
  40. package/package.json +1 -1
@@ -67,6 +67,10 @@ export class EventBridge {
67
67
  }
68
68
  catch { /* ignore */ } });
69
69
  }
70
+ /** ORB-2132 - pin the instance token once the transport session id is known; applies to the next (re)connect. */
71
+ setInstanceToken(token) {
72
+ this.opts = { ...this.opts, instanceToken: token };
73
+ }
70
74
  /** Open the SSE stream and start forwarding. Resolves immediately;
71
75
  * the actual forwarding happens on the returned background loop. */
72
76
  start() {
@@ -114,6 +118,7 @@ export class EventBridge {
114
118
  headers: {
115
119
  Authorization: `Bearer ${bearer}`,
116
120
  Accept: 'text/event-stream',
121
+ ...(this.opts.instanceToken ? { 'x-orboto-agent-session': this.opts.instanceToken } : {}),
117
122
  },
118
123
  signal: this.abort.signal,
119
124
  });
@@ -144,3 +144,37 @@ describe('EventBridge.dispatch', () => {
144
144
  expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(2);
145
145
  });
146
146
  });
147
+ describe('ORB-2132 - the bridge identifies its tool session', () => {
148
+ async function connectOnce(opts) {
149
+ const fetchFn = vi.fn().mockResolvedValue(mockSseResponse([]));
150
+ const bridge = new EventBridge({
151
+ baseUrl: 'https://orboto.example.com',
152
+ apiKey: 'orb_x',
153
+ ...(opts.instanceToken ? { instanceToken: opts.instanceToken } : {}),
154
+ mcp: mockMcp(),
155
+ subscriptions: new Set(),
156
+ fetchFn: fetchFn,
157
+ log: () => { },
158
+ });
159
+ if (opts.pin)
160
+ bridge.setInstanceToken(opts.pin);
161
+ bridge.start();
162
+ await new Promise((r) => setTimeout(r, 30));
163
+ bridge.close();
164
+ expect(fetchFn).toHaveBeenCalledTimes(1);
165
+ const [url, init] = fetchFn.mock.calls[0];
166
+ expect(url).toBe('https://orboto.example.com/sse/mcp-events');
167
+ return init.headers;
168
+ }
169
+ it('sends x-orboto-agent-session with the instance token given at construction', async () => {
170
+ expect(await connectOnce({ instanceToken: 'mcp-session-1' })).toMatchObject({
171
+ Authorization: 'Bearer orb_x', 'x-orboto-agent-session': 'mcp-session-1',
172
+ });
173
+ });
174
+ it('sends the token pinned after construction, once the transport session id is known', async () => {
175
+ expect((await connectOnce({ pin: 'mcp-session-2' }))['x-orboto-agent-session']).toBe('mcp-session-2');
176
+ });
177
+ it('omits the header when no instance token is known', async () => {
178
+ expect(await connectOnce({})).not.toHaveProperty('x-orboto-agent-session');
179
+ });
180
+ });
@@ -11,6 +11,7 @@ import { buildOrbotoMcpServer } from './server.js';
11
11
  import { resolveToolset } from './toolset.js';
12
12
  import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
13
13
  import { EventBridge } from './event-bridge.js';
14
+ import { mcpInstanceToken } from './tools/shared.js';
14
15
  /** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
15
16
  * call builds a short-lived OrbotoClient bound to the request's token so the
16
17
  * api scopes every read/write to that identity. */
@@ -167,12 +168,13 @@ function wwwAuthChallenge(req, baseUrl, error, description) {
167
168
  export function createHttpServer({ baseUrl, sessionStore }) {
168
169
  const sessions = new Map();
169
170
  const store = sessionStore ?? createApiSessionStore(baseUrl);
170
- async function buildSessionCore(tokenHolder, userAgentSuffix, toolset) {
171
+ async function buildSessionCore(tokenHolder, userAgentSuffix, toolset, sessionId) {
171
172
  const tokenProvider = holderTokenProvider(tokenHolder);
172
173
  const sessionClient = new OrbotoClient({ baseUrl, tokenProvider, userAgentSuffix });
173
174
  const subscriptions = new Set();
174
175
  const mcp = await buildOrbotoMcpServer({ baseUrl, tokenProvider, userAgentSuffix, subscriptions, toolset });
175
- const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions });
176
+ const bridge = new EventBridge({ baseUrl, tokenProvider, mcp, subscriptions,
177
+ ...(sessionId ? { instanceToken: mcpInstanceToken(undefined, { sessionId }) } : {}) });
176
178
  return { sessionClient, subscriptions, mcp, bridge };
177
179
  }
178
180
  /** ORB-1576 - resolve a bearer's owner identity via the status preflight;
@@ -188,7 +190,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
188
190
  }
189
191
  }
190
192
  async function establishForcedSession(tokenHolder, userAgentSuffix, chosenSessionId, userEmail, toolset) {
191
- const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset);
193
+ const { sessionClient, mcp, bridge } = await buildSessionCore(tokenHolder, userAgentSuffix, toolset, chosenSessionId);
192
194
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
193
195
  transport.onclose = () => { sessions.delete(chosenSessionId); bridge.close(); };
194
196
  await mcp.connect(transport);
@@ -373,6 +375,7 @@ export function createHttpServer({ baseUrl, sessionStore }) {
373
375
  sessions.set(sid, {
374
376
  transport, mcp, client: sessionClient, bridge, tokenHolder, userEmail: ownerEmail, lastTouchAt: Date.now(),
375
377
  });
378
+ bridge.setInstanceToken(mcpInstanceToken(undefined, { sessionId: sid }));
376
379
  bridge.start();
377
380
  void store
378
381
  .register(token, { sessionId: sid, clientInfo, userAgent })
@@ -24,12 +24,14 @@ afterEach(() => { vi.restoreAllMocks(); });
24
24
  * edits - a description essay creeping back in is exactly what these
25
25
  * must catch, so the headroom is deliberately tighter than the old
26
26
  * 20 %. Shrink-only: raising either number is a conscious decision to
27
- * grow every session's connect cost.
27
+ * grow every session's connect cost. ORB-2132 raised the full ceiling by
28
+ * 2,000 chars because the agent_presence output schema now carries kind,
29
+ * actsAs, projects and connections (measured full = 165,759 chars).
28
30
  *
29
31
  * @see ORB-1805, ORB-1669, ORB-1910
30
32
  */
31
33
  const CURATED_MAX_CHARS = 37_500;
32
- const FULL_MAX_CHARS = 165_000;
34
+ const FULL_MAX_CHARS = 167_000;
33
35
  /**
34
36
  * ORB-1805 - the estimator the ticket measured the failure with
35
37
  * (`n_keep: 13533 >= n_ctx: 8192` in LM Studio): characters / 3.6. It is
@@ -80,7 +80,7 @@ export class OrbotoClient {
80
80
  }
81
81
  /** GET a JSON endpoint. Throws `OrbotoApiError` on non-2xx. */
82
82
  async get(path, options = {}) {
83
- const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal });
83
+ const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal, ...(options.instanceToken ? { headers: { 'x-orboto-agent-session': options.instanceToken } } : {}) });
84
84
  return (await res.json());
85
85
  }
86
86
  /** POST a JSON body. */
package/dist/server.js CHANGED
@@ -23,9 +23,11 @@ import { apiCallToolConfig, makeApiCallHandler } from './tools/api-call.js';
23
23
  import { listAgentInstructionsToolConfig, makeListAgentInstructionsHandler, createAgentInstructionToolConfig, makeCreateAgentInstructionHandler, updateAgentInstructionToolConfig, makeUpdateAgentInstructionHandler, resetAgentInstructionToolConfig, makeResetAgentInstructionHandler, deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler, } from './tools/agent-instructions.js';
24
24
  import { agentHeartbeatToolConfig, makeAgentHeartbeatHandler, agentPresenceToolConfig, makeAgentPresenceHandler, agentNotifyToolConfig, makeAgentNotifyHandler, } from './tools/agent-coordination.js';
25
25
  import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/agent-coordination.js';
26
+ import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
26
27
  import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
27
28
  import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
28
29
  import { getProjectToolConfig, makeGetProjectHandler } from './tools/get-project.js';
30
+ import { projectReadinessToolConfig, makeProjectReadinessHandler } from './tools/project-readiness.js';
29
31
  import { getProjectPrimerToolConfig, makeGetProjectPrimerHandler } from './tools/get-project-primer.js';
30
32
  import { listTicketsToolConfig, makeListTicketsHandler } from './tools/list-tickets.js';
31
33
  import { criticalPathToolConfig, makeCriticalPathHandler } from './tools/critical-path.js';
@@ -39,6 +41,7 @@ import { searchToolConfig, makeSearchHandler } from './tools/search.js';
39
41
  import { queryToolConfig, makeQueryHandler } from './tools/query.js';
40
42
  import { customerReportToolConfig, makeCustomerReportHandler } from './tools/customer-report.js';
41
43
  import { requirementsSpecToolConfig, makeRequirementsSpecHandler } from './tools/requirements-spec.js';
44
+ import { specCheckToolConfig, makeSpecCheckHandler } from './tools/spec-check.js';
42
45
  import { wikiIngestUrlToolConfig, makeWikiIngestUrlHandler, wikiAskToolConfig, makeWikiAskHandler, wikiLintToolConfig, makeWikiLintHandler, wikiPlanUpdateToolConfig, makeWikiPlanUpdateHandler, wikiApplyPlanToolConfig, makeWikiApplyPlanHandler, wikiRecordToolConfig, makeWikiRecordHandler, wikiAppendSectionToolConfig, makeWikiAppendSectionHandler, wikiFlagStaleToolConfig, makeWikiFlagStaleHandler, wikiSaveAnswerToolConfig, makeWikiSaveAnswerHandler, } from './tools/wiki.js';
43
46
  import { personalFactListToolConfig, makePersonalFactListHandler, personalFactAddToolConfig, makePersonalFactAddHandler, personalFactUpdateToolConfig, makePersonalFactUpdateHandler, personalFactDeleteToolConfig, makePersonalFactDeleteHandler, } from './tools/personal-facts.js';
44
47
  import { listDocSpacesToolConfig, makeListDocSpacesHandler, getDocToolConfig, makeGetDocHandler, createDocSpaceToolConfig, makeCreateDocSpaceHandler, updateDocSpaceToolConfig, makeUpdateDocSpaceHandler, deleteDocSpaceToolConfig, makeDeleteDocSpaceHandler, listDocsInSpaceToolConfig, makeListDocsInSpaceHandler, createDocToolConfig, makeCreateDocHandler, updateDocToolConfig, makeUpdateDocHandler, deleteDocToolConfig, makeDeleteDocHandler, moveDocToolConfig, makeMoveDocHandler, duplicateDocSpaceToolConfig, makeDuplicateDocSpaceHandler, resolveDocSmartLinksToolConfig, makeResolveDocSmartLinksHandler, } from './tools/docs.js';
@@ -53,6 +56,8 @@ import { listCrossProjectLinksToolConfig, makeListCrossProjectLinksHandler, addC
53
56
  import { getTimerToolConfig, makeGetTimerHandler } from './tools/get-timer.js';
54
57
  import { getChecklistsToolConfig, makeGetChecklistsHandler } from './tools/get-checklists.js';
55
58
  import { listGitAppInstallationsToolConfig, makeListGitAppInstallationsHandler } from './tools/list-git-app-installations.js';
59
+ import { gitRotateTokenToolConfig, makeGitRotateTokenHandler } from './tools/git-rotate-token.js';
60
+ import { sentryToolConfig, makeSentryHandler } from './tools/sentry.js';
56
61
  import { createTicketToolConfig, makeCreateTicketHandler, updateTicketToolConfig, makeUpdateTicketHandler, moveTicketToolConfig, makeMoveTicketHandler, closeTicketToolConfig, makeCloseTicketHandler, deleteTicketToolConfig, makeDeleteTicketHandler, commentToolConfig, makeCommentHandler, updateCommentToolConfig, makeUpdateCommentHandler, deleteCommentToolConfig, makeDeleteCommentHandler, assignToolConfig, makeAssignHandler, unassignToolConfig, makeUnassignHandler, labelTicketToolConfig, makeLabelTicketHandler, unlabelTicketToolConfig, makeUnlabelTicketHandler, setMilestoneToolConfig, makeSetMilestoneHandler, addTicketDependencyToolConfig, makeAddTicketDependencyHandler, removeTicketDependencyToolConfig, makeRemoveTicketDependencyHandler, listTicketDependenciesToolConfig, makeListTicketDependenciesHandler, } from './tools/ticket-writes.js';
57
62
  import { timerStartToolConfig, makeTimerStartHandler, timerStopToolConfig, makeTimerStopHandler, logTimeToolConfig, makeLogTimeHandler, listTimeEntriesToolConfig, makeListTimeEntriesHandler, editTimeEntryToolConfig, makeEditTimeEntryHandler, deleteTimeEntryToolConfig, makeDeleteTimeEntryHandler, } from './tools/time-writes.js';
58
63
  import { listTicketSchedulesToolConfig, makeListTicketSchedulesHandler, scheduleTicketSessionToolConfig, makeScheduleTicketSessionHandler, cancelTicketSessionToolConfig, makeCancelTicketSessionHandler, } from './tools/ticket-schedules.js';
@@ -191,6 +196,7 @@ export async function buildOrbotoMcpServer(opts) {
191
196
  reg('orboto_agent_heartbeat', agentHeartbeatToolConfig, makeAgentHeartbeatHandler(client));
192
197
  reg('orboto_agent_presence', agentPresenceToolConfig, makeAgentPresenceHandler(client));
193
198
  reg('orboto_agent_notify', agentNotifyToolConfig, makeAgentNotifyHandler(client));
199
+ reg('orboto_message_work', agentMessageWorkToolConfig, makeAgentMessageWorkHandler(client));
194
200
  reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
195
201
  reg('orboto_agent_broadcast', agentBroadcastToolConfig, makeAgentBroadcastHandler(client));
196
202
  reg('orboto_list_projects', listProjectsToolConfig, makeListProjectsHandler(client));
@@ -209,6 +215,7 @@ export async function buildOrbotoMcpServer(opts) {
209
215
  reg('orboto_query', queryToolConfig, makeQueryHandler(client));
210
216
  reg('orboto_customer_report', customerReportToolConfig, makeCustomerReportHandler(client));
211
217
  reg('orboto_requirements_spec', requirementsSpecToolConfig, makeRequirementsSpecHandler(client));
218
+ reg('orboto_spec_check', specCheckToolConfig, makeSpecCheckHandler(client));
212
219
  reg('orboto_list_doc_spaces', listDocSpacesToolConfig, makeListDocSpacesHandler(client));
213
220
  reg('orboto_get_doc', getDocToolConfig, makeGetDocHandler(client));
214
221
  reg('orboto_search_docs', searchDocsToolConfig, makeSearchDocsHandler(client));
@@ -261,6 +268,8 @@ export async function buildOrbotoMcpServer(opts) {
261
268
  reg('orboto_personal_fact_delete', personalFactDeleteToolConfig, makePersonalFactDeleteHandler(client));
262
269
  reg('orboto_get_timer', getTimerToolConfig, makeGetTimerHandler(client));
263
270
  reg('orboto_list_git_app_installations', listGitAppInstallationsToolConfig, makeListGitAppInstallationsHandler(client));
271
+ reg('orboto_git_rotate_token', gitRotateTokenToolConfig, makeGitRotateTokenHandler(client));
272
+ reg('orboto_sentry', sentryToolConfig, makeSentryHandler(client));
264
273
  reg('orboto_create_ticket', createTicketToolConfig, makeCreateTicketHandler(client));
265
274
  reg('orboto_update_ticket', updateTicketToolConfig, makeUpdateTicketHandler(client));
266
275
  reg('orboto_move_ticket', moveTicketToolConfig, makeMoveTicketHandler(client));
@@ -336,6 +345,7 @@ export async function buildOrbotoMcpServer(opts) {
336
345
  reg('orboto_attach_to_ticket', attachToTicketToolConfig, makeAttachToTicketHandler(client));
337
346
  reg('orboto_set_parent', setParentToolConfig, makeSetParentHandler(client));
338
347
  reg('orboto_update_project', updateProjectToolConfig, makeUpdateProjectHandler(client));
348
+ reg('orboto_project_readiness', projectReadinessToolConfig, makeProjectReadinessHandler(client));
339
349
  reg('orboto_create_project', createProjectToolConfig, makeCreateProjectHandler(client));
340
350
  reg('orboto_archive_project', archiveProjectToolConfig, makeArchiveProjectHandler(client));
341
351
  reg('orboto_check_similar', checkSimilarToolConfig, makeCheckSimilarHandler(client));
package/dist/tool-docs.js CHANGED
@@ -13,7 +13,7 @@ export const SUMMARY_MAX_CHARS = 220;
13
13
  */
14
14
  const SUMMARY_OVERRIDES = {
15
15
  orboto_create_full_backup: 'Start a full-workspace backup job (all projects, users, config, attachments); returns the job id to poll via orboto_list_backups.',
16
- orboto_get_ticket: 'Fetch one ticket by key (ORB-42) or UUID: full detail incl. description, status, assignees, checklists, dependencies and comments.',
16
+ orboto_get_ticket: 'Fetch one ticket by key or UUID with description, status, assignees, checklists and comments.',
17
17
  orboto_requirements_spec: 'Generate a structured requirements spec for a project or milestone from its tickets (scope, actors, functional + non-functional requirements).',
18
18
  orboto_update_doc_space: 'Update a doc space\'s name, description, icon, project binding or access mode (open/restricted) by space id.',
19
19
  orboto_search_docs: 'Full-text search over doc/wiki pages (query, optional space or project filter); returns matching pages with snippets.',
@@ -4,6 +4,7 @@
4
4
  * @see ORB-704, ORB-706
5
5
  */
6
6
  import { z } from 'zod';
7
+ import { AgentInventoryEntrySchema, AgentInventoryKindSchema } from './agent-presence-schema.js';
7
8
  import { mcpInstanceToken } from './shared.js';
8
9
  export const agentHeartbeatToolConfig = {
9
10
  title: 'Agent heartbeat (Multi-Agent Coordination)',
@@ -37,35 +38,28 @@ export function makeAgentHeartbeatHandler(client) {
37
38
  }
38
39
  export const agentPresenceToolConfig = {
39
40
  title: 'Workspace agent presence',
40
- description: 'Returns currently-active agent sessions in the workspace. Active = heartbeat within the last 90 s. Super-admins see every agent; regular users see only their own sessions (useful for "is my dispatcher daemon alive?" checks). Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, and the ticket the agent is working on if any. Use this to plan multi-agent work - e.g. before dispatching a sub-task, look up which other agents are active and what they\'re working on so you don\'t step on a parallel run.',
41
- inputSchema: z.object({}).shape,
41
+ description: 'Returns all active orboto agent instances, including lane workers/reviewers, inbox and MCP connections and 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 (useful for "is my dispatcher daemon alive?" checks). Each row exposes `userId`, `userEmail`, the agent runtime (`clientInfo.name`), declared `capabilities`, current `status`, and the ticket the agent is working on if any. Use this to plan multi-agent work - e.g. before dispatching a sub-task, look up which other agents are active and what they\'re working on so you don\'t 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.',
42
+ inputSchema: z.object({
43
+ projectKey: z.string().min(1).max(64).optional().describe('Keep only rows whose projects contain this project key (case-insensitive).'),
44
+ kind: AgentInventoryKindSchema.optional().describe('Keep only rows classified as agent or human by their credential.'),
45
+ }).shape,
42
46
  outputSchema: z.object({
43
- sessions: z.array(z.object({
44
- userId: z.string().uuid(),
45
- userEmail: z.string(),
46
- userFullName: z.string().nullable(),
47
- sessionId: z.string().uuid(),
48
- status: z.string(),
49
- workingOnTicket: z.object({
50
- id: z.string().uuid(),
51
- key: z.string().nullable(),
52
- title: z.string(),
53
- projectKey: z.string().nullable(),
54
- }).nullable(),
55
- capabilities: z.array(z.string()),
56
- clientInfo: z.record(z.string(), z.string()),
57
- lastSeenAt: z.string(),
58
- createdAt: z.string(),
59
- })),
47
+ sessions: z.array(AgentInventoryEntrySchema),
60
48
  }).shape,
61
49
  annotations: { readOnlyHint: true, idempotentHint: true },
62
50
  };
51
+ /** Client-side view filter (ORB-2135); the inventory route stays unfiltered and the chat mirror applies the same rule. */
52
+ export function filterPresenceRows(rows, args) {
53
+ const key = args.projectKey?.trim().toUpperCase();
54
+ return rows.filter((row) => (!args.kind || row.kind === args.kind) && (!key || row.projects.some((p) => p.key.toUpperCase() === key)));
55
+ }
63
56
  export function makeAgentPresenceHandler(client) {
64
- return async () => {
65
- const sessions = await client.get('/v1/agent/presence');
57
+ return async (args = {}) => {
58
+ const all = await client.get('/v1/agent/inventory');
59
+ const sessions = filterPresenceRows(all, args);
66
60
  const lines = [];
67
61
  if (sessions.length === 0) {
68
- lines.push('No active agent sessions in the workspace.');
62
+ lines.push(all.length === 0 ? 'No active agent sessions in the workspace.' : `No active agent sessions match the filter (${all.length} active in the workspace).`);
69
63
  }
70
64
  else {
71
65
  lines.push(`${sessions.length} active session(s):`);
@@ -75,7 +69,7 @@ export function makeAgentPresenceHandler(client) {
75
69
  const work = s.workingOnTicket
76
70
  ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})`
77
71
  : '';
78
- lines.push(`- ${name} (${runtime}) - ${s.status}${work}`);
72
+ 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(', ')}` : ''}`);
79
73
  }
80
74
  }
81
75
  return {
@@ -43,16 +43,16 @@ describe('orboto_agent_heartbeat', () => {
43
43
  });
44
44
  });
45
45
  describe('orboto_agent_presence', () => {
46
- it('GETs /v1/agent/presence and renders one line per session', async () => {
46
+ it('GETs /v1/agent/inventory and renders one line per session', async () => {
47
47
  stub([{
48
48
  json: [
49
49
  {
50
50
  userId: '00000000-0000-0000-0000-000000000001',
51
51
  userEmail: 'alice@x.test',
52
- userFullName: 'Alice',
52
+ userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [], workSessions: [],
53
53
  sessionId: '00000000-0000-0000-0000-000000000010',
54
54
  status: 'working',
55
- workingOnTicket: { id: 't1', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
55
+ workingOnTicket: { id: '00000000-0000-0000-0000-000000000020', key: 'ORB-42', title: 'Test', projectKey: 'ORB' },
56
56
  capabilities: ['writes-tickets'],
57
57
  clientInfo: { name: 'claude-code' },
58
58
  lastSeenAt: new Date().toISOString(),
@@ -61,7 +61,8 @@ describe('orboto_agent_presence', () => {
61
61
  {
62
62
  userId: '00000000-0000-0000-0000-000000000002',
63
63
  userEmail: 'bot@x.test',
64
- userFullName: null,
64
+ userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' },
65
+ autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'dispatcher' }], workSessions: [],
65
66
  sessionId: '00000000-0000-0000-0000-000000000011',
66
67
  status: 'idle',
67
68
  workingOnTicket: null,
@@ -76,8 +77,9 @@ describe('orboto_agent_presence', () => {
76
77
  const result = await handler();
77
78
  const text = result.content[0].text;
78
79
  expect(text).toContain('2 active session(s)');
79
- expect(text).toContain('Alice (claude-code) - working · working on [ORB] ORB-42');
80
- expect(text).toContain('bot@x.test (dispatcher-daemon) - idle');
80
+ expect(text).toContain('Alice <alice@x.test> (claude-code, instance 00000000-0000-0000-0000-000000000010) - working · working on [ORB] ORB-42');
81
+ expect(text).toContain('bot@x.test <bot@x.test> (dispatcher-daemon, instance 00000000-0000-0000-0000-000000000011) - idle; owner: Alice');
82
+ expect(vi.mocked(fetch).mock.calls[0][0]).toBe('https://orboto.example.com/v1/agent/inventory');
81
83
  expect(result.structuredContent.sessions).toHaveLength(2);
82
84
  });
83
85
  it('handles an empty workspace gracefully', async () => {
@@ -86,6 +88,38 @@ describe('orboto_agent_presence', () => {
86
88
  const result = await handler();
87
89
  expect(result.content[0].text).toBe('No active agent sessions in the workspace.');
88
90
  });
91
+ const presenceRows = () => [
92
+ { userId: '00000000-0000-0000-0000-000000000001', userEmail: 'alice@x.test', userFullName: 'Alice', kind: 'human', isBot: false, actsAs: null, owner: null, autonomyPaused: false, lane: null, projects: [{ id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'api_key', label: 'laptop' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000010', status: 'working', workingOnTicket: null, capabilities: [], clientInfo: { name: 'claude-code' }, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
93
+ { userId: '00000000-0000-0000-0000-000000000002', userEmail: 'worker@x.test', userFullName: null, kind: 'agent', isBot: true, actsAs: null, owner: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, autonomyPaused: false, lane: { id: '00000000-0000-0000-0000-000000000040', name: 'worker-1', role: 'implementation', paused: false }, projects: [{ id: '00000000-0000-0000-0000-000000000031', key: 'ACME', name: 'Acme' }, { id: '00000000-0000-0000-0000-000000000030', key: 'ORB', name: 'orboto' }], connections: [{ type: 'lane', label: 'worker-1' }, { type: 'live_events', label: 'MCP live events' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000011', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: { name: 'pi' }, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
94
+ { userId: '00000000-0000-0000-0000-000000000001', userEmail: 'alice@x.test', userFullName: 'Alice', kind: 'agent', isBot: true, actsAs: { id: '00000000-0000-0000-0000-000000000001', name: 'Alice', email: 'alice@x.test' }, owner: null, autonomyPaused: false, lane: null, projects: [], connections: [{ type: 'oauth', label: 'claude.ai' }], workSessions: [], sessionId: '00000000-0000-0000-0000-000000000012', status: 'idle', workingOnTicket: null, capabilities: [], clientInfo: {}, lastSeenAt: new Date().toISOString(), createdAt: new Date().toISOString() },
95
+ ];
96
+ it('filters by projectKey (case-insensitive, projects only) and kind on the client side (ORB-2135)', async () => {
97
+ const handler = makeAgentPresenceHandler(client);
98
+ const ids = async (args) => {
99
+ stub([{ json: presenceRows() }]);
100
+ const result = await handler(args);
101
+ expect(vi.mocked(fetch).mock.calls.at(-1)?.[0]).toBe('https://orboto.example.com/v1/agent/inventory');
102
+ return result.structuredContent.sessions.map((s) => s.sessionId.slice(-2));
103
+ };
104
+ expect(await ids({})).toEqual(['10', '11', '12']);
105
+ expect(await ids({ kind: 'agent' })).toEqual(['11', '12']);
106
+ expect(await ids({ kind: 'human' })).toEqual(['10']);
107
+ expect(await ids({ projectKey: 'acme' })).toEqual(['11']);
108
+ expect(await ids({ projectKey: 'ORB', kind: 'human' })).toEqual(['10']);
109
+ expect(await ids({ projectKey: 'NONE' })).toEqual([]);
110
+ });
111
+ it('says when a filter matched nothing but the workspace is not empty', async () => {
112
+ stub([{ json: presenceRows() }]);
113
+ const result = await makeAgentPresenceHandler(client)({ projectKey: 'NONE' });
114
+ expect(result.content[0].text).toBe('No active agent sessions match the filter (3 active in the workspace).');
115
+ });
116
+ it('renders acts-as, lane, projects and connection types on each line', async () => {
117
+ stub([{ json: presenceRows() }]);
118
+ const text = (await makeAgentPresenceHandler(client)({ kind: 'agent' })).content[0].text;
119
+ expect(text).toContain('2 active session(s)');
120
+ expect(text).toContain('owner: Alice; lane: worker-1; projects: ACME, ORB; connections: lane, live_events');
121
+ expect(text).toContain('owner: acts as Alice; connections: oauth');
122
+ });
89
123
  });
90
124
  describe('orboto_agent_notify', () => {
91
125
  it('POSTs to /v1/agent/notify with the body the user supplied', async () => {
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { mcpInstanceToken } from './shared.js';
3
+ export const agentMessageWorkToolConfig = {
4
+ title: 'Inspect or explicitly handle message work',
5
+ description: 'List, inspect or mutate durable message work (mutation schema: orboto_api_search). ACK is a separate read receipt and finishes nothing.',
6
+ inputSchema: z.object({ messageId: z.string().uuid().optional(), mutation: z.record(z.unknown()).optional(),
7
+ cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(25), openOnly: z.boolean().default(true) }).shape,
8
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
9
+ };
10
+ export function makeAgentMessageWorkHandler(client) {
11
+ return async (args, extra) => {
12
+ if (args.mutation && !args.messageId)
13
+ throw new Error('messageId is required for a mutation');
14
+ const q = new URLSearchParams({ limit: String(args.limit ?? 25), openOnly: String(args.openOnly ?? true) });
15
+ if (args.cursor)
16
+ q.set('cursor', args.cursor);
17
+ const result = args.mutation
18
+ ? await client.post(`/v1/agent/messages/${args.messageId}/work`, { ...args.mutation, instanceToken: mcpInstanceToken(undefined, extra) })
19
+ : await client.get(`${args.messageId ? `/v1/agent/messages/${args.messageId}` : '/v1/agent/message-work'}?${q}`, { instanceToken: mcpInstanceToken(undefined, extra) });
20
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
21
+ };
22
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './agent-message-work.js';
4
+ import { makeAgentMessagesHandler } from './agent-messages.js';
5
+ describe('message work MCP parity', () => {
6
+ it('carries no second copy of the shared mutation schema', () => {
7
+ const shared = readFileSync(new URL('../../../../packages/shared-schema/src/agent-message-work.ts', import.meta.url), 'utf8');
8
+ const local = readFileSync(new URL('./agent-message-work.ts', import.meta.url), 'utf8');
9
+ const stepBlock = shared.slice(shared.indexOf('export const MessageWorkStepSchema'), shared.indexOf('export const MessageWorkMutationSchema'));
10
+ expect(local).not.toContain(stepBlock.replaceAll('export const', 'const'));
11
+ expect(local).not.toContain('MessageWorkStepSchema');
12
+ expect(agentMessageWorkToolConfig.description).toContain('orboto_api_search');
13
+ });
14
+ it('uses each MCP connection identity and reads exact IDs without ACK', async () => {
15
+ const get = vi.fn().mockResolvedValueOnce({ message: { id: 'message' } }).mockResolvedValue({ messages: [] });
16
+ const post = vi.fn().mockResolvedValue({ state: 'in_work' });
17
+ const client = { get, post };
18
+ const handler = makeAgentMessageWorkHandler(client);
19
+ await handler({ messageId: 'message' }, { sessionId: 'a' });
20
+ expect(get).toHaveBeenCalledWith('/v1/agent/messages/message?limit=25&openOnly=true', { instanceToken: 'mcp-a' });
21
+ expect(post).not.toHaveBeenCalled();
22
+ await handler({ messageId: 'message', mutation: { action: 'claim', summary: 'Explicit acceptance', evidence: [], leaseSeconds: 900 } }, { sessionId: 'a' });
23
+ expect(post).toHaveBeenCalledWith('/v1/agent/messages/message/work', expect.objectContaining({ instanceToken: 'mcp-a' }));
24
+ });
25
+ it('ACK is a plain read receipt - no evidence travels with it', async () => {
26
+ const get = vi.fn().mockResolvedValue({ messages: [] });
27
+ const post = vi.fn().mockResolvedValue({ acked: 1 });
28
+ const client = { get, post };
29
+ await makeAgentMessagesHandler(client)({ ackIds: ['message'] }, { sessionId: 'b' });
30
+ expect(post).toHaveBeenCalledWith('/v1/agent/messages/ack', { ids: ['message'], instanceToken: 'mcp-b' });
31
+ expect(JSON.stringify(post.mock.calls)).not.toContain('evidence');
32
+ });
33
+ });
@@ -8,18 +8,22 @@
8
8
  * api's `x-orboto-agent-mail` response header - no polling anywhere).
9
9
  */
10
10
  import { z } from 'zod';
11
+ import { makeAgentMessageWorkHandler } from './agent-message-work.js';
11
12
  import { mcpInstanceToken } from './shared.js';
12
13
  export const agentMessagesToolConfig = {
13
- title: 'Fetch / acknowledge your agent inbox',
14
- description: 'Your store-and-forward inbox: messages other agents sent you, broadcasts to your scopes (payload.broadcast names the scope) and ticket_ready wake-ups (subject ticket-ready:<KEY>, payload.type ticket_ready with ticketKey, reason assigned | labeled | unblocked: the ticket is yours to claim; claim or answer, then ack). Default = unread, marked delivered on fetch; ackIds marks read. Reply via orboto_agent_notify with threadId = the message id.',
14
+ title: 'Agent inbox and message work',
15
+ description: 'Your inbox. messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. Reply via orboto_agent_notify with threadId.',
15
16
  inputSchema: z.object({
16
- all: z.boolean().default(false).describe('true = include already-read messages'),
17
+ messageWork: z.record(z.unknown()).optional()
18
+ .describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
19
+ all: z.boolean().default(false),
17
20
  limit: z.number().int().min(1).max(200).default(50),
18
- project: z.string().min(1).max(64).optional().describe('Project key or UUID: scoped messages for this project plus unscoped ones. Ack only messages that are yours.'),
19
- includeOwnSends: z.boolean().default(false).describe('true = also list messages this session sent (hidden by default so a shared identity never wakes itself).'),
20
- ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark as read'),
21
+ project: z.string().min(1).max(64).optional().describe('Project key/UUID; includes unscoped mail.'),
22
+ includeOwnSends: z.boolean().default(false),
23
+ ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark read.'),
21
24
  }).shape,
22
25
  outputSchema: z.object({
26
+ work: z.record(z.unknown()).optional(),
23
27
  messages: z.array(z.object({
24
28
  id: z.string(),
25
29
  fromUserId: z.string(),
@@ -33,13 +37,19 @@ export const agentMessagesToolConfig = {
33
37
  })),
34
38
  acked: z.number().int(),
35
39
  }).shape,
36
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
40
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
37
41
  };
38
42
  export function makeAgentMessagesHandler(client) {
39
43
  return async (args, extra) => {
44
+ if (args.messageWork) {
45
+ if (args.ackIds?.length)
46
+ throw new Error('Use a separate explicit ACK call.');
47
+ const result = await makeAgentMessageWorkHandler(client)(args.messageWork, extra);
48
+ return { content: result.content, structuredContent: { messages: [], acked: 0, work: result.structuredContent } };
49
+ }
40
50
  let acked = 0;
41
51
  if (args.ackIds && args.ackIds.length > 0) {
42
- const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds });
52
+ const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
43
53
  acked = res.acked;
44
54
  }
45
55
  const q = new URLSearchParams();
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ const WorkSessionResourceClaimSchema = z.object({
3
+ kind: z.enum(['path', 'named']), value: z.string().min(1).max(500), mode: z.enum(['read', 'write']),
4
+ state: z.enum(['granted', 'waiting']).optional(), requestedAt: z.string().optional(),
5
+ });
6
+ export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
7
+ export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web']);
8
+ export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
9
+ export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
10
+ export const AgentInventoryEntrySchema = z.object({
11
+ userId: z.string().uuid(),
12
+ userEmail: z.string(),
13
+ userFullName: z.string().nullable(),
14
+ kind: AgentInventoryKindSchema,
15
+ /** Alias of `kind === 'agent'`. */
16
+ isBot: z.boolean(),
17
+ actsAs: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
18
+ owner: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
19
+ autonomyPaused: z.boolean(),
20
+ sessionId: z.string().uuid(),
21
+ status: z.string(),
22
+ lane: z.object({ id: z.string().uuid(), name: z.string(), role: z.string(), paused: z.boolean() }).nullable(),
23
+ projects: z.array(AgentInventoryProjectSchema),
24
+ connections: z.array(AgentInventoryConnectionSchema),
25
+ workingOnTicket: z.object({ id: z.string().uuid(), key: z.string().nullable(), title: z.string(), projectKey: z.string().nullable() }).nullable(),
26
+ workSessions: z.array(z.object({
27
+ ticketId: z.string().uuid(), ticketKey: z.string().nullable(), role: z.string(), leaseUntil: z.string(),
28
+ resourceClaims: z.array(WorkSessionResourceClaimSchema),
29
+ })),
30
+ capabilities: z.array(z.string()),
31
+ clientInfo: z.record(z.string(), z.string()),
32
+ lastSeenAt: z.string(),
33
+ createdAt: z.string(),
34
+ });
35
+ export const AgentInventorySchema = z.array(AgentInventoryEntrySchema);
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * @see ORB-179, ORB-181
5
5
  */
6
+ import { specGateFailure, isAlreadyAssigned } from './spec-schemas.js';
6
7
  import { z } from 'zod';
7
8
  import { OrbotoApiError } from '../orboto-client.js';
8
9
  import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
@@ -15,33 +16,44 @@ const CATEGORY_TO_LEGACY = {
15
16
  };
16
17
  export const claimToolConfig = {
17
18
  title: 'Claim a ticket (assign self + in_progress + timer)',
18
- description: 'Composite of `assign self → move to in_progress → start timer` - the canonical "I am picking this up now" move. Idempotent: re-claiming an already-claimed in_progress ticket is a no-op. Set `sole=true` to remove every other assignee first (destructive take-over). Set `force=true` to allow re-claiming a `done` ticket (otherwise refuses, to prevent accidental reopens). Set `noTimer=true` to skip the timer start (e.g. when you only want ownership, not time tracking). If a different ticket has an active timer, it is stopped first (its elapsed time commits a time entry under the previous ticket). '
19
- + '`agentSessionToken` is a stable per-agent-instance token: on a bot/service account it scopes the timer to YOUR instance - concurrent per-instance timers and NO auto-stop, so you own both start and stop. Omit it on human accounts, which keep the single-timer behaviour.',
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.',
20
20
  inputSchema: z.object({
21
21
  ticketKey: z.string().min(3),
22
- sole: z.boolean().optional().describe('Take-over: remove every other assignee first.'),
23
- force: z.boolean().optional().describe('Allow re-claiming a `done` ticket.'),
24
- noTimer: z.boolean().optional().describe('Skip the timer start.'),
25
- agentSessionToken: z.string().optional().describe('Per-agent-instance token; scopes the timer on bot accounts.'),
22
+ override: z.boolean().optional().describe("Spec override; reason required."),
23
+ reason: z.string().trim().min(1).max(2000).optional(),
24
+ sole: z.boolean().optional().describe('Remove other assignees after gate.'),
25
+ force: z.boolean().optional().describe('Reopen done.'),
26
+ noTimer: z.boolean().optional().describe('No timer.'),
27
+ agentSessionToken: z.string().optional().describe('Instance token for bot timers.'),
26
28
  }).shape,
27
29
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
28
30
  };
29
31
  export function makeClaimHandler(client) {
30
- return async ({ ticketKey, sole, force, noTimer, agentSessionToken }, extra) => {
32
+ return async ({ ticketKey, sole, force, override, reason, noTimer, agentSessionToken }, extra) => {
31
33
  const me = await client.get('/users/me');
32
34
  const current = await resolveTicketByKey(client, ticketKey);
33
35
  const currentAssignees = current.assignees ?? [];
34
- const alreadyAssigned = currentAssignees.some((a) => a.id === me.id);
36
+ const alreadyAssigned = currentAssignees.some((a) => (a.userId ?? a.id) === me.id);
35
37
  const currentCategory = current.statusCategory;
36
38
  if (currentCategory === 'done' && !force) {
37
39
  throw new Error(`Refusing to claim [${current.ticketKey}]: status is "done". Pass force=true to reopen + claim.`);
38
40
  }
41
+ try {
42
+ await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, { override, reason });
43
+ }
44
+ catch (error) {
45
+ const gate = error instanceof OrbotoApiError && error.status === 409 ? specGateFailure(error.body) : undefined;
46
+ if (gate)
47
+ return gate;
48
+ if (!(error instanceof OrbotoApiError) || error.status !== 409 || !isAlreadyAssigned(error.body))
49
+ throw error;
50
+ }
39
51
  if (sole) {
40
52
  for (const a of currentAssignees) {
41
- if (a.id === me.id)
53
+ if ((a.userId ?? a.id) === me.id)
42
54
  continue;
43
55
  try {
44
- await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.id}`);
56
+ await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.userId ?? a.id}`);
45
57
  }
46
58
  catch (err) {
47
59
  if (!(err instanceof OrbotoApiError) || err.status !== 404)
@@ -49,15 +61,6 @@ export function makeClaimHandler(client) {
49
61
  }
50
62
  }
51
63
  }
52
- if (!alreadyAssigned) {
53
- try {
54
- await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, {});
55
- }
56
- catch (err) {
57
- if (!(err instanceof OrbotoApiError) || err.status !== 409)
58
- throw err;
59
- }
60
- }
61
64
  let finalTicket = current;
62
65
  if (currentCategory !== 'in_progress') {
63
66
  finalTicket = await client.patch(`/projects/${current.projectId}/tickets/${current.id}`, { status: CATEGORY_TO_LEGACY.in_progress });