@orboto/mcp 0.232.0 → 0.232.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.
@@ -0,0 +1,66 @@
1
+ /** ORB-2684 - the origin remote of the MCP server's checkout as a heartbeat's `repository`, parsed like the Go CLI and the wrapper (checkout-project.mjs). */
2
+ import { spawnSync } from 'node:child_process';
3
+ const SCP_REMOTE = /^(?:[^@/\s]+@)?[^:/\s]+:(.+)$/;
4
+ /** Owner and name of an https, ssh or scp-like remote on any host; null when it names none. */
5
+ export function parseRemote(raw) {
6
+ const remote = raw.trim();
7
+ let path;
8
+ if (remote.includes('://')) {
9
+ try {
10
+ path = new URL(remote).pathname;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ else {
17
+ const match = SCP_REMOTE.exec(remote);
18
+ if (!match)
19
+ return null;
20
+ path = match[1];
21
+ }
22
+ let parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
23
+ if (parts[0] === 'v3' && parts.length === 4)
24
+ parts = [parts[1], parts[3]];
25
+ const git = parts.indexOf('_git');
26
+ if (git > 0 && git + 1 < parts.length)
27
+ parts = [parts[0], parts[git + 1]];
28
+ if (parts[0] === 'scm' && parts.length > 2)
29
+ parts = parts.slice(1);
30
+ if (parts.length < 2)
31
+ return null;
32
+ const name = parts[parts.length - 1].replace(/\.git$/, '');
33
+ const owner = parts.slice(0, -1).join('/').replace(/^~/, '');
34
+ return owner && name ? { owner, name } : null;
35
+ }
36
+ /** The host of a remote, empty when it names none. */
37
+ export function remoteHost(raw) {
38
+ const remote = raw.trim();
39
+ if (remote.includes('://')) {
40
+ try {
41
+ return new URL(remote).hostname;
42
+ }
43
+ catch {
44
+ return '';
45
+ }
46
+ }
47
+ const match = SCP_REMOTE.exec(remote);
48
+ if (!match)
49
+ return '';
50
+ const head = remote.slice(0, remote.length - match[1].length - 1);
51
+ return head.slice(head.lastIndexOf('@') + 1);
52
+ }
53
+ /** The origin remote's URL of the checkout at `cwd`; empty outside a repository or without one. */
54
+ function originUrl(cwd) {
55
+ const res = spawnSync('git', ['-C', cwd, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', timeout: 2000 });
56
+ return res.status === 0 ? String(res.stdout ?? '').trim() : '';
57
+ }
58
+ /** The checkout's repository for a heartbeat, or null when the working directory has no parsable origin remote. */
59
+ export function checkoutRepository(cwd = process.cwd(), read = originUrl) {
60
+ const remote = read(cwd);
61
+ const parsed = remote ? parseRemote(remote) : null;
62
+ if (!parsed)
63
+ return null;
64
+ const host = remoteHost(remote).toLowerCase();
65
+ return { owner: parsed.owner.slice(0, 255), name: parsed.name.slice(0, 255), ...(host ? { host: host.slice(0, 255) } : {}) };
66
+ }
@@ -0,0 +1,43 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /** ORB-2684 - the checkout repository a heartbeat names parses remotes the way the CLI and the wrapper do. */
10
+ import { describe, expect, it } from 'vitest';
11
+ import { checkoutRepository, parseRemote, remoteHost } from './checkout-repository.js';
12
+ import { inboxViewLine } from './tools/agent-messages.js';
13
+ const REMOTES = [
14
+ 'https://github.com/orboto/orboto.git',
15
+ 'git@github.com:orboto/orboto.git',
16
+ 'ssh://git@10.0.0.5:2222/acme/acme.shop.git',
17
+ 'https://dev.azure.com/org/project/_git/repo',
18
+ 'https://bitbucket.example.org/scm/team/repo.git',
19
+ 'https://gitlab.example.org/group/sub/repo',
20
+ 'not a remote',
21
+ '',
22
+ ];
23
+ describe('ORB-2684 - checkout repository', () => {
24
+ it('parses every remote form exactly like the wrapper', async () => {
25
+ const wrapperPath = '../../../skills/orboto/scripts/checkout-project.mjs';
26
+ const wrapper = await import(__rewriteRelativeImportExtension(wrapperPath));
27
+ for (const remote of REMOTES) {
28
+ expect(parseRemote(remote), remote).toEqual(wrapper.parseRemote(remote));
29
+ expect(remoteHost(remote), remote).toBe(wrapper.remoteHost(remote));
30
+ }
31
+ });
32
+ it('names owner, name and the lower-cased host; nothing without a parsable origin', () => {
33
+ expect(checkoutRepository('/x', () => 'ssh://git@Forgejo.Example:2222/SilentFuture/game-repo.git')).toEqual({ owner: 'SilentFuture', name: 'game-repo', host: 'forgejo.example' });
34
+ expect(checkoutRepository('/x', () => '')).toBeNull();
35
+ expect(checkoutRepository('/x', () => 'just-a-name')).toBeNull();
36
+ });
37
+ it('the inbox view line names the checkout projects and the hint, and stays silent for a declared view', () => {
38
+ expect(inboxViewLine({ view: 'checkout', projectKeys: ['MSC'], hint: 'Declare one.' })).toBe('Inbox view: checkout - this session lists mail addressed to it, broadcasts and mail of MSC (its checkout). Declare one.');
39
+ expect(inboxViewLine({ view: 'addressed_only', projectKeys: [], hint: 'Declare one.' })).toBe('Inbox view: addressed_only - this session lists mail addressed to it and broadcasts. Declare one.');
40
+ expect(inboxViewLine({ view: 'declared', projectKeys: ['ORB'], hint: null })).toBeNull();
41
+ expect(inboxViewLine(undefined)).toBeNull();
42
+ });
43
+ });
package/dist/index.js CHANGED
@@ -80,9 +80,11 @@ async function main() {
80
80
  void inbox.deliverNotice(CLI_OUTDATED_NOTICE, outdated);
81
81
  const { readClaudeContext, channelClientInfo } = await import('./claude-context.js');
82
82
  const { presenceRefusal } = await import('./tools/shared.js');
83
+ const { checkoutRepository } = await import('./checkout-repository.js');
84
+ const repository = checkoutRepository();
83
85
  let refusalLogged = false;
84
86
  const beat = () => {
85
- preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd()), undefined, channelStart ?? undefined) }, { instanceToken })
87
+ preflightClient.post('/v1/agent/heartbeat', { clientInfo: channelClientInfo(readClaudeContext(process.cwd()), undefined, channelStart ?? undefined), ...(repository ? { repository } : {}) }, { instanceToken })
86
88
  .then((res) => {
87
89
  refusalLogged = false;
88
90
  const joined = adoptJoined(res);
@@ -110,7 +112,8 @@ async function main() {
110
112
  else {
111
113
  const { toolServerHeartbeat } = await import('./claude-context.js');
112
114
  const { beatAsToolServer } = await import('./tools/shared.js');
113
- beatAsToolServer(preflightClient, toolServerHeartbeat())
115
+ const { checkoutRepository } = await import('./checkout-repository.js');
116
+ beatAsToolServer(preflightClient, toolServerHeartbeat(), checkoutRepository())
114
117
  .catch((err) => { console.error(`[orboto-mcp] heartbeat failed: ${err.message}`); });
115
118
  }
116
119
  return;
@@ -65,6 +65,10 @@ afterEach(() => { vi.restoreAllMocks(); });
65
65
  * ORB-2579 added the four rule proposal tools to the full tier after
66
66
  * tightening repeated parameter descriptions (doc ids, bulk selectors,
67
67
  * work-session leases, primer facts) by 3.0k (full = 169,607).
68
+ * ORB-2684 gave the scope input of `orboto_session_start` and
69
+ * `orboto_agent_heartbeat` the `account` boolean (+58) inside the ceiling:
70
+ * both titles and the session_start summary shrank to pay for it
71
+ * (full = 171,194).
68
72
  *
69
73
  * @see ORB-1805, ORB-1669, ORB-1910
70
74
  */
@@ -8,13 +8,13 @@ import { AGENT_WAKE_LABELS, AgentInventoryKindSchema, AgentSessionWakeSchema, }
8
8
  import { AgentPresenceRowSchema } from './agent-presence-row.js';
9
9
  import { OrbotoApiError } from '../orboto-client.js';
10
10
  import { mcpInstanceToken, presenceRefusal } from './shared.js';
11
- import { AgentSessionRoleSchema, AgentSessionScopeSchema } from './agent-session-scope.js';
11
+ import { AgentSessionRoleSchema, AgentSessionScopeInputSchema, AgentSessionScopeSchema } from './agent-session-scope.js';
12
12
  export const agentHeartbeatToolConfig = {
13
- title: 'Agent heartbeat (Multi-Agent Coordination)',
14
- description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it. `startedBy`: starter session id, same account.',
13
+ title: 'Agent heartbeat',
14
+ description: 'Register or refresh this agent\'s presence with status detail. A live connection already counts as online (an open MCP event stream, `orboto messages --follow`, the agent WebSocket) - the heartbeat adds status: idle (default) | working (+workingOnTicketId) | blocked, capabilities (free-form strings for operator filters) and clientInfo.name (the runtime), and is the only presence path for turn-based clients without a standing connection. Rows older than 90 s count as offline; persist the returned sessionToken and send it on later heartbeats. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility (ORB-2136); an empty object clears it; `account: true` = account view. `startedBy`: starter session id, same account.',
15
15
  inputSchema: z.object({
16
16
  sessionToken: z.string().nullable().optional(),
17
- scope: AgentSessionScopeSchema.optional(),
17
+ scope: AgentSessionScopeInputSchema.optional(),
18
18
  status: z.enum(['idle', 'working', 'blocked']).optional(),
19
19
  workingOnTicketId: z.string().uuid().nullable().optional(),
20
20
  capabilities: z.array(z.string()).max(200).optional(),
@@ -123,7 +123,7 @@ function presenceLine(s) {
123
123
  const work = s.workingOnTicket ? ` · working on [${s.workingOnTicket.projectKey ?? '?'}] ${s.workingOnTicket.key ?? s.workingOnTicket.id} (${s.workingOnTicket.title})` : '';
124
124
  const owner = s.owner?.name ?? s.owner?.email ?? (s.actsAs ? `acts as ${s.actsAs.name ?? s.actsAs.email}` : s.kind === 'agent' ? 'unassigned' : 'self');
125
125
  const declared = s.scope?.projectKeys ?? [];
126
- const scope = s.scope?.role || declared.length ? `; scope: ${s.scope?.role ?? 'no role'}${declared.length ? ` on ${declared.join(', ')}` : ''}` : '';
126
+ const scope = s.scope?.role || declared.length ? `; scope: ${s.scope?.role ?? 'no role'}${declared.length ? ` on ${declared.join(', ')}` : ''}${s.scope?.account ? ' (account view)' : ''}` : '';
127
127
  const keys = s.projects.map((p) => p.key);
128
128
  const projects = keys.length === 0 ? '' : keys.length <= 8 ? `; projects: ${keys.join(', ')}` : `; member of ${keys.length} projects`;
129
129
  const connections = s.connections.length ? `; connections: ${[...new Set(s.connections.map((c) => c.type))].join(', ')}` : '';
@@ -22,6 +22,13 @@ export function repeatMarks(m) {
22
22
  ].filter(Boolean);
23
23
  return marks.length ? ` [${marks.join(', ')}]` : '';
24
24
  }
25
+ /** ORB-2684 - the line a session without declared keys reads under its inbox; nothing for a declared or explicit view. */
26
+ export function inboxViewLine(inbox) {
27
+ if (!inbox?.hint)
28
+ return null;
29
+ const holds = inbox.view === 'checkout' && inbox.projectKeys.length ? `mail addressed to it, broadcasts and mail of ${inbox.projectKeys.join(', ')} (its checkout)` : 'mail addressed to it and broadcasts';
30
+ return `Inbox view: ${inbox.view} - this session lists ${holds}. ${inbox.hint}`;
31
+ }
25
32
  /** ORB-2548 - the compact page an MCP inbox read returns by default, sized so it fits the 4,000-character budget. */
26
33
  export const COMPACT_PAGE_LIMIT = 4;
27
34
  const UNREGISTERED_INBOX_HINT = 'Not registered: only account mail is listed. Register with orboto_session_start to see the mail addressed to your session.';
@@ -143,7 +150,7 @@ export function makeAgentMessagesHandler(client) {
143
150
  q.set('includeOwn', 'true');
144
151
  else if (args.direction !== 'sent')
145
152
  q.set('excludeRef', instanceToken);
146
- const { messages, sessionId, counts, nextCursor } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
153
+ const { messages, sessionId, counts, nextCursor, inbox } = await client.get(`/v1/agent/messages?${q.toString()}`, { instanceToken });
147
154
  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}` : ''})`;
148
155
  const lines = messages.length === 0
149
156
  ? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
@@ -166,6 +173,9 @@ export function makeAgentMessagesHandler(client) {
166
173
  lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
167
174
  else
168
175
  lines.push(UNREGISTERED_INBOX_HINT);
176
+ const view = inboxViewLine(inbox);
177
+ if (view)
178
+ lines.push(view);
169
179
  return {
170
180
  content: [{ type: 'text', text: lines.join('\n') }],
171
181
  structuredContent: {
@@ -180,6 +190,7 @@ export function makeAgentMessagesHandler(client) {
180
190
  dismissed: dismissedCount,
181
191
  closed: closedCount,
182
192
  nextCursor: nextCursor ?? null,
193
+ ...(inbox ? { inbox } : {}),
183
194
  },
184
195
  };
185
196
  };
@@ -6,3 +6,5 @@ export const AgentSessionScopeSchema = z.object({
6
6
  ticketKeys: z.array(z.string()).max(200).optional(),
7
7
  role: AgentSessionRoleSchema.optional(),
8
8
  });
9
+ /** ORB-2684 - the declarable scope: `account` is the explicit account view of an integrator or a coordinator. */
10
+ export const AgentSessionScopeInputSchema = AgentSessionScopeSchema.extend({ account: z.boolean().optional() });
@@ -6,7 +6,7 @@ import { z } from 'zod';
6
6
  import { AGENT_WAKE_LABELS } from './agent-presence-schema.js';
7
7
  export const agentWakesToolConfig = {
8
8
  title: 'Wake ledger',
9
- description: 'The wake ledger: who woke this account\'s sessions and at what cost. It names the visibility rule that delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender instead of describing it in prose.',
9
+ description: 'The wake ledger: who woke this account\'s sessions and at what cost. It names the visibility rule that delivered the message (addressed | scope_project | scope_ticket | broadcast | unscoped_session | account_view | checkout_project | account_scope), what the woken session did with it (handled | not_mine | obsolete | duplicate | expired | pending) and what the wake cost in context tokens. `stats: true` returns delivery precision per woken session and per sender over `hours` (operator read, needs admin:agents:read); `wakes`, `precision` and the median latency count deliveries (`channel` | `follow` | `poll`) only, `backlogSeen` is the separate count of `nudge` rows - a backlog sighting, not a wake. Without `stats` it returns the ledger rows of your own account, newest first. Use it to prove a misrouting sender.',
10
10
  inputSchema: z.object({
11
11
  stats: z.boolean().default(false).describe('Precision per session and per sender instead of rows.'),
12
12
  hours: z.number().int().min(1).max(720).optional().describe('Stats window, default 24.'),
@@ -11,18 +11,19 @@
11
11
  import { z } from 'zod';
12
12
  import { resolveTicketByKey, applyAgentProfile, mcpInstanceToken, localSessionHost, presenceRefusal, exitedPredecessor } from './shared.js';
13
13
  import { machineId, shortHost } from '../session-origin.js';
14
- import { AgentSessionScopeSchema } from './agent-session-scope.js';
14
+ import { AgentSessionScopeInputSchema } from './agent-session-scope.js';
15
15
  import { PROCESS_OWNER, PROTECT_TEXT_META, storePayload } from '../response-budget.js';
16
16
  import { loadRequiredRules } from '../required-rules.js';
17
17
  import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
18
18
  import { channelStartLines } from '../inbox-channel.js';
19
19
  import { rememberDeclaredScope } from '../session-scope-memory.js';
20
20
  import { declareEnvProjectScope, envProjectScope, supervisorScopePending } from '../project-scope.js';
21
+ import { checkoutRepository } from '../checkout-repository.js';
21
22
  import { agentHeadText } from '../agent-head.js';
22
23
  import { agentContextKey } from '../agent-context.js';
23
24
  export const sessionStartToolConfig = {
24
- title: 'Load the rules you must follow + re-orient',
25
- description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it FIRST in a session and AFTER every context compaction. Returns the rules in full (above the full-text bound: one title per rule plus every must-read rule in full, the rest via `rulesOnly: true`); a compact "unchanged" ack only on a repeat inside the same agent context. Also returns your in-progress tickets (LANDED, IDLE = a linked commit but no move for days: finished work never handed to review), your timer and unhealthy git connections. Not orboto_list_agent_instructions - that one edits rule blocks for admins. Registers this session (a heartbeat on its instance row), otherwise read-only. '
25
+ title: 'Load the rules + re-orient',
26
+ description: 'THE canonical way to LOAD the binding workspace rules you must follow. Run it FIRST in a session and AFTER every context compaction. Returns the rules in full (above the full-text bound: one title per rule plus every must-read rule in full, the rest via `rulesOnly: true`); a compact "unchanged" ack only on a repeat inside the same agent context. Also returns your in-progress tickets (LANDED, IDLE = a linked commit but no move for days: finished work never handed to review), your timer and unhealthy git connections. Not orboto_list_agent_instructions - that one edits rule blocks for admins. Registers this session (a heartbeat on its instance row), otherwise read-only. '
26
27
  + '**Parameters.** `rulesOnly: true` returns ONLY the complete rules text, never truncated. `projectId` adds that project\'s rules. `ticketKey` ("ACME-42") bundles that ticket\'s primer, detail, dependencies, checklists, git health and other agent sessions. `forceRules: true` returns the full text inline even above the bound or after an ack - use it whenever the rules are NOT in your context. `agentKind` (coding, orchestrator, reviewer, runner) and `modelTier` (frontier, standard, small) target rule blocks and per-tier text. `scope` { projectKeys, ticketKeys, role } declares this session\'s responsibility; session.ref is what peers pass as toSessionRef (ORB-2136). `previous` (adopt | end) answers a second-start refusal (ORB-2553); it comes only when the scope overlaps another live session of the account in this checkout (the keys in errorParams.overlap) - a scope that shares no project or ticket with it runs beside it (ORB-2639). '
27
28
  + 'Also carries the Knowledge block (ORB-2225: the wiki index of your declared projects and the Operations wiki; ask with orboto_knowledge_ask before a person, file durable facts with orboto_knowledge_add) and the Responsibilities block (ORB-2542: who owns the topics of your projects, orboto_who before agent mail). Right after the rules come Where knowledge lives (ORB-2575: rules bind behaviour, project facts hold fixed decisions, the wiki the long tail; a decision goes to a fact proposal, orboto_primer_fact_add with observed: true) and the Project facts of your declared projects.',
28
29
  inputSchema: z.object({
@@ -32,7 +33,7 @@ export const sessionStartToolConfig = {
32
33
  rulesOnly: z.boolean().optional(),
33
34
  agentKind: z.string().min(1).max(32).optional(),
34
35
  modelTier: z.string().min(1).max(32).optional(),
35
- scope: AgentSessionScopeSchema.optional(),
36
+ scope: AgentSessionScopeInputSchema.optional(),
36
37
  previous: z.enum(['adopt', 'end']).optional(),
37
38
  }).shape,
38
39
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
@@ -238,6 +239,8 @@ export function makeSessionStartHandler(client, opts = {}) {
238
239
  wake: r.wake, scopeExpired: r.scopeExpired ?? null, scopeContinued: r.scopeContinued ?? null, notes: Array.isArray(r.notes) ? r.notes.filter((n) => typeof n === 'string') : [],
239
240
  ...(r.takeover ? { takeover: r.takeover } : {}),
240
241
  ...(r.takeovers?.length ? { takeovers: r.takeovers } : {}),
242
+ checkoutProjectKeys: Array.isArray(r.checkoutProjectKeys) ? r.checkoutProjectKeys : [],
243
+ ...(r.inbox ? { inbox: r.inbox } : {}),
241
244
  } : null);
242
245
  let refusal = null;
243
246
  let alsoOverlapping = '';
@@ -249,10 +252,12 @@ export function makeSessionStartHandler(client, opts = {}) {
249
252
  return null;
250
253
  };
251
254
  const host = localSessionHost(extra);
252
- const fallbackScope = input.scope === undefined && !supervisorScopePending() ? envProjectScope() : null;
255
+ const pending = supervisorScopePending();
256
+ const fallbackScope = input.scope === undefined && !pending ? envProjectScope() : null;
257
+ const repository = pending || !host ? null : checkoutRepository();
253
258
  const startBody = {
254
259
  start: true, ...(input.previous ? { previous: input.previous } : {}), ...(input.scope !== undefined ? { scope: input.scope } : {}),
255
- ...(fallbackScope ? { fallbackScope } : {}), ...(host ? { clientInfo: { host } } : {}),
260
+ ...(fallbackScope ? { fallbackScope } : {}), ...(host ? { clientInfo: { host } } : {}), ...(repository ? { repository } : {}),
256
261
  };
257
262
  const beat = (body) => client.post('/v1/agent/heartbeat', body, { instanceToken }).then(toRegistration);
258
263
  let exitedLine = null;
@@ -273,6 +278,16 @@ export function makeSessionStartHandler(client, opts = {}) {
273
278
  refusal = declared.refusal;
274
279
  registration = toRegistration(declared) ?? (declared?.refusal ? null : registration);
275
280
  }
281
+ let inferredFrom = null;
282
+ const inferred = input.scope === undefined && !fallbackScope && registration && !registration.scope && registration.checkoutProjectKeys.length === 1 ? registration.checkoutProjectKeys[0] : null;
283
+ if (inferred) {
284
+ const declared = await beat({ start: true, scope: { role: 'worker', projectKeys: [inferred] }, scopeDefault: true, ...(repository ? { repository } : {}) }).catch(refused);
285
+ if (declared?.scope) {
286
+ registration = declared;
287
+ rememberDeclaredScope(instanceToken, declared.scope);
288
+ inferredFrom = `inferred from the checkout's origin remote ${repository ? `${repository.owner}/${repository.name}` : ''}`.trim();
289
+ }
290
+ }
276
291
  const [me, rules, assigned, timer, inboxRaw, knowledge, owners, projectFacts] = await Promise.all([
277
292
  client.get('/users/me').catch(() => null),
278
293
  loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
@@ -378,12 +393,14 @@ export function makeSessionStartHandler(client, opts = {}) {
378
393
  const scopeText = registration?.scope
379
394
  ? [registration.scope.role ? `role ${registration.scope.role}` : null, registration.scope.projectKeys?.length ? `projects ${registration.scope.projectKeys.join(', ')}` : null, registration.scope.ticketKeys?.length ? `tickets ${registration.scope.ticketKeys.join(', ')}` : null].filter(Boolean).join('; ')
380
395
  : registration
381
- ? 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it'
396
+ ? `no scope declared - ${registration.inbox?.hint ?? 'this session gets only mail addressed to it, broadcasts and mail of its checkout\'s project'} With MCP: pass scope: { role, projectKeys }`
382
397
  : 'registration failed - only account mail is listed; retry orboto_session_start or check mcp:use';
383
398
  if (refusal)
384
399
  lines.push(`ref ${instanceToken}. Not registered: ${refusal}`, `With MCP: pass scope: { role, projectKeys }; another live session in this repository whose scope overlaps runs beside this one once the scope shares none of its keys, or pass previous: "adopt" or "end" to replace ${overlappingTarget(alsoOverlapping)}.`);
385
400
  else
386
401
  lines.push(`ref ${instanceToken}${registration ? `, instance ${registration.sessionId.slice(0, 8)}` : ''}. ${scopeText}. Peers reach exactly this session with orboto_agent_notify { toSessionRef: "${registration ? registration.sessionId.slice(0, 8) : instanceToken}" }.`);
402
+ if (inferredFrom)
403
+ lines.push(`Scope ${inferredFrom}; pass scope: { role, projectKeys } to declare another.`);
387
404
  if (exitedLine)
388
405
  lines.push(exitedLine);
389
406
  else if (registration?.takeovers?.length)
@@ -18,8 +18,10 @@ import { makeResponseExpandHandler } from './response-expand.js';
18
18
  import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
19
19
  import { hostname, tmpdir } from 'node:os';
20
20
  import { localSessionHost } from './shared.js';
21
+ const checkout = vi.hoisted(() => ({ repository: null }));
22
+ vi.mock('../checkout-repository.js', () => ({ checkoutRepository: () => checkout.repository }));
21
23
  /** An `orboto claude` or `orboto connect` session exports its checkout project and role; the digest must not inherit them here. */
22
- beforeEach(() => { vi.restoreAllMocks(); vi.stubEnv('ORBOTO_PROJECT_KEY', ''); vi.stubEnv('ORBOTO_SESSION_ROLE', ''); });
24
+ beforeEach(() => { vi.restoreAllMocks(); vi.stubEnv('ORBOTO_PROJECT_KEY', ''); vi.stubEnv('ORBOTO_SESSION_ROLE', ''); checkout.repository = null; });
23
25
  afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
24
26
  function stubByPath(map) {
25
27
  const calls = [];
@@ -672,6 +674,35 @@ describe('ORB-2136 - session scope and ref', () => {
672
674
  await makeSessionStartHandler(client)({ scope: { role: 'worker', projectKeys: ['orb'] } }, { sessionId: 'keep1' });
673
675
  expect(rememberedScope('mcp-keep1')).toEqual({ role: 'worker', projectKeys: ['ORB'] });
674
676
  });
677
+ it('ORB-2684 - a stdio start sends the checkout repository; one project it maps to is declared as the worker scope and printed, none leaves the hint', async () => {
678
+ checkout.repository = { owner: 'SilentFuture', name: 'game-repo', host: 'git.example.org' };
679
+ const beats = [];
680
+ let mapped = ['MSC'];
681
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
682
+ const u = new URL(url.toString());
683
+ const sent = init?.body ? JSON.parse(init.body) : undefined;
684
+ if (u.pathname === '/v1/agent/heartbeat')
685
+ beats.push(sent);
686
+ const body = u.pathname === '/v1/agent/heartbeat'
687
+ ? { sessionToken: 'x', sessionId: 'cbb52195-0000-4000-8000-000000000000', scope: sent?.scope ?? null, checkoutProjectKeys: mapped,
688
+ inbox: { view: mapped.length ? 'checkout' : 'addressed_only', projectKeys: mapped, hint: 'This session declared no scope, so it gets only mail addressed to it.' } }
689
+ : u.pathname === '/agent-instructions' ? { instructions: 'rules here', rulesHash: 'fixture' } : {};
690
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
691
+ });
692
+ const declared = (await makeSessionStartHandler(client)({})).content[0].text;
693
+ expect(beats).toEqual([
694
+ { start: true, clientInfo: { host: hostname().trim().toLowerCase() }, repository: checkout.repository },
695
+ { start: true, scope: { role: 'worker', projectKeys: ['MSC'] }, scopeDefault: true, repository: checkout.repository },
696
+ ]);
697
+ expect(declared).toContain('role worker; projects MSC');
698
+ expect(declared).toContain("Scope inferred from the checkout's origin remote SilentFuture/game-repo");
699
+ beats.length = 0;
700
+ mapped = [];
701
+ const unmapped = (await makeSessionStartHandler(client)({ forceRules: true })).content[0].text;
702
+ expect(beats).toHaveLength(1);
703
+ expect(unmapped).toContain('no scope declared - This session declared no scope, so it gets only mail addressed to it.');
704
+ expect(unmapped).not.toContain('inferred from');
705
+ });
675
706
  it('ORB-2465 - a stdio session reports its host on the heartbeat; an HTTP connection reports none', async () => {
676
707
  const seen = [];
677
708
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
@@ -744,7 +775,7 @@ describe('ORB-2347 - an unregistered caller is told it sees account mail only',
744
775
  '/agent-instructions': { instructions: 'rules', rulesHash: 'h2' },
745
776
  });
746
777
  const registered = (await makeSessionStartHandler(client)({ forceRules: true })).content[0];
747
- expect(registered.text).toContain('lists every account-addressed message');
778
+ expect(registered.text).toContain('no scope declared - this session gets only mail addressed to it');
748
779
  expect(registered.text).not.toContain('not registered');
749
780
  });
750
781
  });
@@ -137,8 +137,8 @@ export function adoptJoined(res) {
137
137
  return MCP_PROCESS_INSTANCE === token ? token : null;
138
138
  }
139
139
  /** ORB-2641 - the one heartbeat of a stdio server without a channel: it says so, and joins the session of its process when the answer names one. */
140
- export async function beatAsToolServer(client, clientInfo) {
141
- const res = await client.post('/v1/agent/heartbeat', { clientInfo }, { instanceToken: MCP_PROCESS_INSTANCE });
140
+ export async function beatAsToolServer(client, clientInfo, repository) {
141
+ const res = await client.post('/v1/agent/heartbeat', { clientInfo, ...(repository ? { repository } : {}) }, { instanceToken: MCP_PROCESS_INSTANCE });
142
142
  return adoptJoined(res);
143
143
  }
144
144
  /** ORB-2465 - the machine a stdio proxy runs on, lower-cased, as `agent-broadcast live --host` matches it; an HTTP connection (per-connection session id) runs on the server, so it reports none. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.232.0",
3
+ "version": "0.232.1",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",