@orboto/mcp 0.199.0 → 0.200.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.
- package/README.md +19 -1
- package/dist/agent-head.js +51 -0
- package/dist/agent-head.test.js +63 -0
- package/dist/claude-context.js +47 -0
- package/dist/claude-context.test.js +38 -0
- package/dist/connect-script.js +30 -0
- package/dist/connect-script.test.js +42 -0
- package/dist/inbox-channel.js +47 -5
- package/dist/inbox-channel.test.js +18 -0
- package/dist/index.js +11 -4
- package/dist/manifest-size.test.js +11 -3
- package/dist/orboto-client.js +3 -0
- package/dist/orboto-client.test.js +15 -0
- package/dist/server.js +3 -1
- package/dist/tool-parameters.test.js +5 -0
- package/dist/tools/agent-coordination.test.js +1 -1
- package/dist/tools/agent-messages.js +30 -2
- package/dist/tools/agent-presence-schema.js +11 -0
- package/dist/tools/agent-wakes.js +61 -0
- package/dist/tools/api-call.test.js +1 -1
- package/dist/tools/docs.js +3 -2
- package/dist/tools/help.js +20 -3
- package/dist/tools/session-start.js +6 -2
- package/dist/tools/session-start.test.js +33 -0
- package/dist/tools/shared.js +8 -2
- package/dist/tools/shared.test.js +8 -0
- package/dist/tools/work-sessions-next.test.js +1 -1
- package/dist/tools/work-sessions-start.test.js +1 -1
- package/dist/tools/work-sessions.test.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,25 @@ Model Context Protocol server for [orboto](https://orboto.io) - a self-hosted ti
|
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The shortest path is the [orboto CLI](https://www.npmjs.com/package/orboto):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i -g orboto
|
|
13
|
+
orboto login
|
|
14
|
+
orboto mcp install # writes the "orboto" server entry into ./.mcp.json
|
|
15
|
+
orboto mcp install --scope user # or into the Claude Code user config
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The entry it writes runs `orboto mcp serve`, which installs and starts
|
|
19
|
+
`@orboto/mcp` at **the version of the instance it connects to**, so the tool
|
|
20
|
+
schemas always match that API. The proxy versions are cached under
|
|
21
|
+
`~/.orboto/mcp/<version>/`, the entry carries no credential, and
|
|
22
|
+
`orboto mcp status` prints the instance version, the cached proxies and the
|
|
23
|
+
entry of the current directory. In Claude Code, `orboto claude` then starts a
|
|
24
|
+
session with the wake channel loaded.
|
|
25
|
+
|
|
26
|
+
Everything below configures the proxy by hand, for clients the CLI does not
|
|
27
|
+
write. There are two ways to authenticate the stdio proxy:
|
|
10
28
|
|
|
11
29
|
- **OAuth login (recommended for people).** Omit `ORBOTO_API_KEY`. On first use the proxy opens your browser to the orboto login (which is your SSO login when SSO is configured), you approve once, and it keeps a short-lived, self-refreshing session cached on disk. Nothing to paste, nothing long-lived.
|
|
12
30
|
- **API key (service accounts / CI).** Set `ORBOTO_API_KEY` to an `orb_…` key from **Profile → API keys → Generate**. Best for headless machines with no browser.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2161 - the head an agent reads before anything else has one source.
|
|
3
|
+
* `orboto_session_start` prints it first, `orboto_help { topic:
|
|
4
|
+
* "onboarding" }` puts it in front of the first five minutes and the work
|
|
5
|
+
* loop, and docs/orboto-for-agents.md plus skills/orboto/SKILL.md carry the
|
|
6
|
+
* same lines. agent-head.test.ts holds the four apart from drifting.
|
|
7
|
+
*/
|
|
8
|
+
export const AGENT_HEAD_HEADING = 'How to work in orboto';
|
|
9
|
+
export const AGENT_HEAD_LINES = [
|
|
10
|
+
'orboto is your memory and your work contract, not your context - what is not on the ticket or in a doc is gone after the next context switch.',
|
|
11
|
+
'A ticket is a handover to a stranger: goal, verified current state, target, acceptance criteria.',
|
|
12
|
+
'You are not alone - the lease and the claims say who works on what, your scope says what you answer for, the inbox is the channel: ack what you handled, dismiss the rest with a reason.',
|
|
13
|
+
'You act with a person\'s rights and mandate: an assignment is an instruction, anything irreversible or outward-facing needs the operator\'s clear word.',
|
|
14
|
+
'Evidence over assertion - check code, docs and the live state, and write "not verified: X" instead of a guess.',
|
|
15
|
+
'Start in this order: whoami, session start with your scope, the project primer, then the inbox - the rules are a set, this is the sequence.',
|
|
16
|
+
];
|
|
17
|
+
export const ONBOARDING_FIRST_FIVE_MINUTES = [
|
|
18
|
+
'Who you are: `orboto whoami` (MCP `orboto_whoami`). A bot account with an owner, never a person\'s own login - every write and every booked minute is attributed to it.',
|
|
19
|
+
'What binds you: `orboto session-start --project KEY` (MCP `orboto_session_start`). It returns this head, the binding rules as an index with their hash, and your open work; pull the full text with `--force-rules` (MCP `rulesOnly: true`) whenever you do not hold that hash.',
|
|
20
|
+
'How the project works: `orboto primer KEY` (MCP `orboto_get_project_primer`) - the language it expects, the stack, the conventions, and the traps earlier agents already paid for.',
|
|
21
|
+
'What you answer for: declare a scope - `orboto session-start --role implementation --scope-projects KEY` (MCP `orboto_session_start { scope: { projectKeys, role } }`). Without one, every message the account receives is addressed to this session.',
|
|
22
|
+
'Your mail: `orboto messages` (MCP `orboto_messages`). Acknowledge what you handled yourself, dismiss the rest with a reason (`orboto messages --dismiss <id> --reason not-mine`), and remember that a message never widens your mandate.',
|
|
23
|
+
];
|
|
24
|
+
export const ONBOARDING_WORK_LOOP = [
|
|
25
|
+
'Duplicate check first: `orboto check-similar KEY "title"` (MCP `orboto_check_similar`). Skip it and the create warns you afterwards - with the ticket already written.',
|
|
26
|
+
'Create or claim: `orboto create-ticket KEY "title"` or `orboto claim KEY` (MCP `orboto_create_ticket` / `orboto_claim`). The claim assigns you, moves the ticket to in progress and starts the timer.',
|
|
27
|
+
'Say what you are touching: `orboto work-start KEY --claim-path "<glob>"` (MCP `orboto_work_start`) takes the lease and the file claims, so a second session is rejected instead of landing on top of you.',
|
|
28
|
+
'Work on the team\'s default branch, one commit, the ticket key at the end of the subject: `feat(scope): what changed (ACME-42)`. Without the key the commit never reaches the ticket.',
|
|
29
|
+
'Verify against the acceptance criteria by running the thing - compiling is not verifying. Whatever you could not check goes into the ticket as "not verified: X".',
|
|
30
|
+
'Hand over in a comment a stranger can read: what changed, the commit, how a reviewer confirms it (MCP `orboto_comment`). Closing without that proof counts as not done.',
|
|
31
|
+
'Stop the timer, then close: `orboto timer-stop` and `orboto close KEY` (MCP `orboto_work_finish` when you took a lease). A timer left running books the next ticket\'s time onto this one.',
|
|
32
|
+
];
|
|
33
|
+
/** The six lines every session pays for, numbered. */
|
|
34
|
+
export function agentHeadText() {
|
|
35
|
+
return [AGENT_HEAD_HEADING, ...AGENT_HEAD_LINES.map((line, index) => `${index + 1}. ${line}`)].join('\n');
|
|
36
|
+
}
|
|
37
|
+
/** The `onboarding` help topic: the head, the first five minutes, the work loop. */
|
|
38
|
+
export function onboardingText() {
|
|
39
|
+
return [
|
|
40
|
+
agentHeadText(),
|
|
41
|
+
'',
|
|
42
|
+
'The first five minutes, in this order',
|
|
43
|
+
...ONBOARDING_FIRST_FIVE_MINUTES.map((step, index) => `${index + 1}. ${step}`),
|
|
44
|
+
'',
|
|
45
|
+
'The work loop, always the same',
|
|
46
|
+
...ONBOARDING_WORK_LOOP.map((step, index) => `${index + 1}. ${step}`),
|
|
47
|
+
'',
|
|
48
|
+
'The whole page - the object map, several sessions on one account, the autonomy boundary,',
|
|
49
|
+
'evidence over assertion, and the traps that cost other agents a run: docs/orboto-for-agents.md.',
|
|
50
|
+
].join('\n');
|
|
51
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2161 - the head has one source. Session start prints it before
|
|
3
|
+
* anything else, the onboarding help topic repeats it, and the page and
|
|
4
|
+
* the skill carry the same lines, so a sentence cannot be improved in one
|
|
5
|
+
* place and stay old in the other three.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { AGENT_HEAD_HEADING, AGENT_HEAD_LINES, ONBOARDING_FIRST_FIVE_MINUTES, ONBOARDING_WORK_LOOP, agentHeadText, onboardingText } from './agent-head.js';
|
|
10
|
+
import { makeHelpHandler } from './tools/help.js';
|
|
11
|
+
import { makeSessionStartHandler } from './tools/session-start.js';
|
|
12
|
+
import { OrbotoClient } from './orboto-client.js';
|
|
13
|
+
const read = (relative) => readFileSync(new URL(`../../../${relative}`, import.meta.url), 'utf8');
|
|
14
|
+
const CARRIERS = ['docs/orboto-for-agents.md', 'skills/orboto/SKILL.md'];
|
|
15
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
16
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
17
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
18
|
+
describe('the agent head (ORB-2161)', () => {
|
|
19
|
+
it('stays four to six lines, so every session can afford it', () => {
|
|
20
|
+
expect(AGENT_HEAD_LINES.length).toBeGreaterThanOrEqual(4);
|
|
21
|
+
expect(AGENT_HEAD_LINES.length).toBeLessThanOrEqual(6);
|
|
22
|
+
for (const line of AGENT_HEAD_LINES)
|
|
23
|
+
expect(line).not.toContain('\n');
|
|
24
|
+
});
|
|
25
|
+
it.each(CARRIERS)('%s carries every line verbatim', (file) => {
|
|
26
|
+
const page = read(file);
|
|
27
|
+
for (const line of AGENT_HEAD_LINES)
|
|
28
|
+
expect(page).toContain(line);
|
|
29
|
+
});
|
|
30
|
+
it('is the first thing orboto_session_start prints', async () => {
|
|
31
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
32
|
+
const path = new URL(url.toString()).pathname;
|
|
33
|
+
const body = path.startsWith('/agent-instructions')
|
|
34
|
+
? { instructions: 'claim -> commit -> close', rulesHash: 'fixture' }
|
|
35
|
+
: path.startsWith('/users/me/assigned-tickets') ? { items: [] } : {};
|
|
36
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => body, text: async () => '' };
|
|
37
|
+
});
|
|
38
|
+
const res = await makeSessionStartHandler(client)();
|
|
39
|
+
const text = res.content[0].text;
|
|
40
|
+
expect(text.startsWith(agentHeadText())).toBe(true);
|
|
41
|
+
expect(text.indexOf(AGENT_HEAD_HEADING)).toBeLessThan(text.indexOf('# orboto session start'));
|
|
42
|
+
});
|
|
43
|
+
it('orboto_help returns the head, the first five minutes and the work loop for the topic "onboarding"', async () => {
|
|
44
|
+
const result = await makeHelpHandler()({ topic: 'onboarding' });
|
|
45
|
+
const text = (result.content?.[0]).text;
|
|
46
|
+
for (const line of AGENT_HEAD_LINES)
|
|
47
|
+
expect(text).toContain(line);
|
|
48
|
+
for (const step of ONBOARDING_FIRST_FIVE_MINUTES)
|
|
49
|
+
expect(text).toContain(step);
|
|
50
|
+
for (const step of ONBOARDING_WORK_LOOP)
|
|
51
|
+
expect(text).toContain(step);
|
|
52
|
+
expect(text).toContain('docs/orboto-for-agents.md');
|
|
53
|
+
expect(result.structuredContent).toMatchObject({ tool: 'topic:onboarding' });
|
|
54
|
+
});
|
|
55
|
+
it('names both topics when an unknown one is asked for', async () => {
|
|
56
|
+
const unknown = (await makeHelpHandler()({ topic: 'nope' })).content?.[0];
|
|
57
|
+
expect(unknown.text).toContain('onboarding');
|
|
58
|
+
expect(unknown.text).toContain('connect');
|
|
59
|
+
});
|
|
60
|
+
it('the onboarding topic repeats the head instead of holding a second copy', () => {
|
|
61
|
+
expect(onboardingText().startsWith(agentHeadText())).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2149 - what a wake of this session costs. `orboto claude-statusline`
|
|
3
|
+
* writes one file per Claude Code session; the proxy reports the freshest one
|
|
4
|
+
* for its own directory. Two sessions in one directory, or none, report
|
|
5
|
+
* nothing - the ledger then says "unknown", never an estimate.
|
|
6
|
+
*/
|
|
7
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
export const CLAUDE_CONTEXT_MAX_AGE_MS = 10 * 60_000;
|
|
11
|
+
export function claudeContextDir() {
|
|
12
|
+
return path.join(homedir(), '.orboto', 'claude-context');
|
|
13
|
+
}
|
|
14
|
+
/** The one fresh report for `dir`; ambiguity and absence both mean "unknown". */
|
|
15
|
+
export function readClaudeContext(dir, now = Date.now(), root = claudeContextDir()) {
|
|
16
|
+
let names;
|
|
17
|
+
try {
|
|
18
|
+
names = readdirSync(root).filter((n) => n.endsWith('.json'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const fresh = [];
|
|
24
|
+
for (const name of names) {
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(readFileSync(path.join(root, name), 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (parsed.cwd !== dir)
|
|
33
|
+
continue;
|
|
34
|
+
const updated = typeof parsed.updatedAt === 'string' ? Date.parse(parsed.updatedAt) : NaN;
|
|
35
|
+
if (!Number.isFinite(updated) || now - updated > CLAUDE_CONTEXT_MAX_AGE_MS)
|
|
36
|
+
continue;
|
|
37
|
+
if (typeof parsed.contextTokens !== 'number' || !Number.isFinite(parsed.contextTokens))
|
|
38
|
+
continue;
|
|
39
|
+
fresh.push({
|
|
40
|
+
contextTokens: Math.max(0, Math.round(parsed.contextTokens)),
|
|
41
|
+
...(typeof parsed.contextWindowSize === 'number' && Number.isFinite(parsed.contextWindowSize)
|
|
42
|
+
? { contextWindowSize: Math.max(0, Math.round(parsed.contextWindowSize)) }
|
|
43
|
+
: {}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return fresh.length === 1 ? fresh[0] : null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2149 - the proxy reports a context size only when exactly one fresh
|
|
3
|
+
* report belongs to its directory.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { readClaudeContext } from './claude-context.js';
|
|
10
|
+
let root;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
root = mkdtempSync(path.join(tmpdir(), 'orboto-ctx-'));
|
|
13
|
+
mkdirSync(root, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
|
16
|
+
function write(name, body) {
|
|
17
|
+
writeFileSync(path.join(root, name), JSON.stringify(body));
|
|
18
|
+
}
|
|
19
|
+
describe('ORB-2149 - claude statusline context reports', () => {
|
|
20
|
+
it('reports the one fresh file for this directory', () => {
|
|
21
|
+
write('a.json', { cwd: '/work/repo', contextTokens: 120_000, contextWindowSize: 200_000, updatedAt: new Date().toISOString() });
|
|
22
|
+
write('b.json', { cwd: '/elsewhere', contextTokens: 5, updatedAt: new Date().toISOString() });
|
|
23
|
+
expect(readClaudeContext('/work/repo', Date.now(), root)).toEqual({ contextTokens: 120_000, contextWindowSize: 200_000 });
|
|
24
|
+
});
|
|
25
|
+
it('reports nothing for a stale report, an unknown directory, a broken file or two sessions in one directory', () => {
|
|
26
|
+
write('stale.json', { cwd: '/work/repo', contextTokens: 1, updatedAt: new Date(Date.now() - 20 * 60_000).toISOString() });
|
|
27
|
+
expect(readClaudeContext('/work/repo', Date.now(), root)).toBeNull();
|
|
28
|
+
expect(readClaudeContext('/nothing/here', Date.now(), root)).toBeNull();
|
|
29
|
+
write('broken.json', { cwd: '/work/two' });
|
|
30
|
+
expect(readClaudeContext('/work/two', Date.now(), root)).toBeNull();
|
|
31
|
+
write('one.json', { cwd: '/work/two', contextTokens: 10, updatedAt: new Date().toISOString() });
|
|
32
|
+
write('two.json', { cwd: '/work/two', contextTokens: 20, updatedAt: new Date().toISOString() });
|
|
33
|
+
expect(readClaudeContext('/work/two', Date.now(), root)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
it('reports nothing when the directory does not exist', () => {
|
|
36
|
+
expect(readClaudeContext('/work/repo', Date.now(), path.join(root, 'missing'))).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2157 - the one source of the script an agent follows when a person
|
|
3
|
+
* asks it to set orboto up. `orboto_help { topic: "connect" }` returns it,
|
|
4
|
+
* docs/connect-your-agent.md, docs/claude-code.md and skills/orboto/SKILL.md
|
|
5
|
+
* carry the same sentences, and connect-script.test.ts holds them together.
|
|
6
|
+
*/
|
|
7
|
+
export const CONNECT_SCRIPT_HEADING = 'If you are an AI agent helping a person set this up';
|
|
8
|
+
export const CONNECT_SCRIPT_STEPS = [
|
|
9
|
+
'Work one step per message: say in one sentence what the step does and why, run it, show the result, and only then move on.',
|
|
10
|
+
'Run every step you can run yourself - `orboto connect --yes` checks Node and Claude Code, writes the `orboto` server entry for this directory and prints the exact start command with the standing arguments.',
|
|
11
|
+
'Hand the person only the steps that need a human: the browser login, quitting and restarting the session with that command, confirming the servers from `.mcp.json` and the local-development prompt, and, on a Team or Enterprise plan, the one Owner switch that allows channels.',
|
|
12
|
+
'Give every command the person runs verbatim in its own code block, never paraphrased and never merged into prose.',
|
|
13
|
+
'Verify before you continue: `orboto whoami` for the identity, `orboto mcp status` for the entry of this directory, `orboto connect --verify` for the channel.',
|
|
14
|
+
'Never paste a reference page into the chat - link it and keep the message to the step you are on.',
|
|
15
|
+
'Never ask for an API key, a password or a token in the chat; `orboto login` writes the credential itself.',
|
|
16
|
+
'When a check fails, name the cause from the troubleshooting table, fix it, and repeat the check before the next step.',
|
|
17
|
+
'Finish with `orboto connect --verify` and one real first task, so the person sees their own tickets rather than a success message.',
|
|
18
|
+
];
|
|
19
|
+
/** The help topic's body: the heading, the numbered steps, the pages to link. */
|
|
20
|
+
export function connectScriptText() {
|
|
21
|
+
const steps = CONNECT_SCRIPT_STEPS.map((step, index) => `${index + 1}. ${step}`);
|
|
22
|
+
return [
|
|
23
|
+
CONNECT_SCRIPT_HEADING,
|
|
24
|
+
'',
|
|
25
|
+
...steps,
|
|
26
|
+
'',
|
|
27
|
+
'The pages to link instead of pasting: docs/connect-your-agent.md (the first hour, in plain words),',
|
|
28
|
+
'docs/claude-code.md (start commands, standing arguments, troubleshooting table), docs/mcp-setup.md (any other client).',
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2157 - the setup script has one source. The help topic, the two docs
|
|
3
|
+
* pages and the skill are checked against it, so a sentence cannot be
|
|
4
|
+
* improved in one place and stay old in the other three.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { CONNECT_SCRIPT_HEADING, CONNECT_SCRIPT_STEPS, connectScriptText } from './connect-script.js';
|
|
9
|
+
import { makeHelpHandler } from './tools/help.js';
|
|
10
|
+
const read = (relative) => readFileSync(new URL(`../../../${relative}`, import.meta.url), 'utf8');
|
|
11
|
+
const CARRIERS = ['docs/connect-your-agent.md', 'docs/claude-code.md', 'skills/orboto/SKILL.md'];
|
|
12
|
+
describe('the connect script', () => {
|
|
13
|
+
it('reads as a numbered script with the pages it links', () => {
|
|
14
|
+
const text = connectScriptText();
|
|
15
|
+
expect(text).toContain(CONNECT_SCRIPT_HEADING);
|
|
16
|
+
CONNECT_SCRIPT_STEPS.forEach((step, index) => expect(text).toContain(`${index + 1}. ${step}`));
|
|
17
|
+
expect(text).toContain('docs/connect-your-agent.md');
|
|
18
|
+
});
|
|
19
|
+
it.each(CARRIERS)('%s carries every step verbatim', (file) => {
|
|
20
|
+
const page = read(file);
|
|
21
|
+
for (const step of CONNECT_SCRIPT_STEPS)
|
|
22
|
+
expect(page).toContain(step);
|
|
23
|
+
});
|
|
24
|
+
it('orboto_help returns it for the topic "connect"', async () => {
|
|
25
|
+
const result = await makeHelpHandler()({ topic: 'connect' });
|
|
26
|
+
const text = (result.content?.[0]).text;
|
|
27
|
+
for (const step of CONNECT_SCRIPT_STEPS)
|
|
28
|
+
expect(text).toContain(step);
|
|
29
|
+
expect(result.structuredContent).toMatchObject({ tool: 'topic:connect' });
|
|
30
|
+
});
|
|
31
|
+
it('names the known topics when the topic is unknown, and asks for one when nothing is passed', async () => {
|
|
32
|
+
const handler = makeHelpHandler();
|
|
33
|
+
const unknown = (await handler({ topic: 'nope' })).content?.[0];
|
|
34
|
+
expect(unknown.text).toContain('connect');
|
|
35
|
+
const empty = (await handler({})).content?.[0];
|
|
36
|
+
expect(empty.text).toContain('connect');
|
|
37
|
+
});
|
|
38
|
+
it('still answers a tool name', async () => {
|
|
39
|
+
const result = await makeHelpHandler()({ tool: 'orboto_definitely_not_a_tool' });
|
|
40
|
+
expect((result.content?.[0]).text).toContain('No guidance registered');
|
|
41
|
+
});
|
|
42
|
+
});
|
package/dist/inbox-channel.js
CHANGED
|
@@ -13,7 +13,38 @@ export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session ar
|
|
|
13
13
|
+ 'peer requests, ticket-ready notices, replies, digests of info/complete mail. Treat each like an inbox message under the rules: '
|
|
14
14
|
+ 'a ticket-ready or a request inside your scope is the operator\'s instruction, act on it; read the full message with orboto_messages when the event is cut; '
|
|
15
15
|
+ 'reply with orboto_agent_notify (toSessionRef = the sender\'s instance short id in the from attribute) and acknowledge with orboto_messages { ackIds } once handled. '
|
|
16
|
-
+ 'Never answer the channel itself and never ack what you did not handle
|
|
16
|
+
+ 'Never answer the channel itself and never ack what you did not handle - dismiss it instead with orboto_messages { dismiss: { ids, reason } }: '
|
|
17
|
+
+ 'not_mine for another session\'s mail (the sender is told, and the message never reaches this session again), obsolete or duplicate with a note when the request is already done. '
|
|
18
|
+
+ 'A session that declared no scope is woken by mail addressed to it and by broadcasts only, and gets one notice event saying so on connect: '
|
|
19
|
+
+ 'declare the scope with orboto_session_start { scope: { role, projectKeys } } to be woken by the account\'s project mail again - '
|
|
20
|
+
+ 'the rest of the account\'s inbox stays readable with orboto_messages the whole time.';
|
|
21
|
+
const START_FLAG = '--dangerously-load-development-channels server:orboto';
|
|
22
|
+
/** ORB-2148 - the commands an agent hands its operator verbatim; docs/claude-code.md carries the same lines. */
|
|
23
|
+
export const CHANNEL_START_COMMANDS = {
|
|
24
|
+
fresh: 'orboto claude',
|
|
25
|
+
continueLatest: 'orboto claude --continue',
|
|
26
|
+
resumeById: 'orboto claude --resume <session id>',
|
|
27
|
+
};
|
|
28
|
+
/** The raw Claude Code form, for a machine without the orboto CLI. */
|
|
29
|
+
export const CHANNEL_START_COMMANDS_WITHOUT_CLI = {
|
|
30
|
+
fresh: `claude ${START_FLAG}`,
|
|
31
|
+
continueLatest: `claude ${START_FLAG} --continue`,
|
|
32
|
+
resumeById: `claude ${START_FLAG} --resume <session id>`,
|
|
33
|
+
};
|
|
34
|
+
/** The `orboto_session_start` block of a stdio proxy whose channel is on. */
|
|
35
|
+
export function channelStartLines() {
|
|
36
|
+
return [
|
|
37
|
+
'## Wake channel',
|
|
38
|
+
'This proxy pushes inbox messages into the session as <channel source="orboto"> events, but only when Claude Code was started with the development-channels flag. '
|
|
39
|
+
+ 'Run in this repository (`orboto` is this server\'s key in .mcp.json), and confirm the "local development" prompt Claude Code shows on every such start:',
|
|
40
|
+
`- new session: \`${CHANNEL_START_COMMANDS.fresh}\``,
|
|
41
|
+
`- continue the most recent conversation here: \`${CHANNEL_START_COMMANDS.continueLatest}\``,
|
|
42
|
+
`- resume a specific conversation: \`${CHANNEL_START_COMMANDS.resumeById}\` (without an id Claude Code opens a picker)`,
|
|
43
|
+
'The entry `orboto claude` needs comes from `orboto mcp install`; without the orboto CLI the same starts read '
|
|
44
|
+
+ `\`${CHANNEL_START_COMMANDS_WITHOUT_CLI.fresh}\`, \`${CHANNEL_START_COMMANDS_WITHOUT_CLI.continueLatest}\` and \`${CHANNEL_START_COMMANDS_WITHOUT_CLI.resumeById}\`.`,
|
|
45
|
+
'A probe that produces no channel event means this session was not started that way: give the operator these commands verbatim, do not paraphrase them.',
|
|
46
|
+
];
|
|
47
|
+
}
|
|
17
48
|
function firstLine(payload) {
|
|
18
49
|
if (!payload)
|
|
19
50
|
return '';
|
|
@@ -85,7 +116,7 @@ export class InboxChannel {
|
|
|
85
116
|
seenOrder = [];
|
|
86
117
|
batch = [];
|
|
87
118
|
/** Counters an operator can read from the log; tests read them directly. */
|
|
88
|
-
stats = { delivered: 0, digested: 0, duplicates: 0, reconnects: 0 };
|
|
119
|
+
stats = { delivered: 0, digested: 0, duplicates: 0, reconnects: 0, notices: 0 };
|
|
89
120
|
constructor(opts) {
|
|
90
121
|
this.opts = opts;
|
|
91
122
|
this.log = opts.log ?? ((msg) => { try {
|
|
@@ -135,6 +166,13 @@ export class InboxChannel {
|
|
|
135
166
|
this.digestTimer = setTimeout(() => { this.digestTimer = null; void this.flushDigest(); }, this.digestMinutes * 60_000);
|
|
136
167
|
}
|
|
137
168
|
}
|
|
169
|
+
/** ORB-2151 - a server notice (the scope hint) is emitted as-is and never becomes the replay anchor. */
|
|
170
|
+
async deliverNotice(notice, content) {
|
|
171
|
+
if (!content)
|
|
172
|
+
return;
|
|
173
|
+
this.stats.notices += 1;
|
|
174
|
+
await this.emit({ content, meta: { kind: 'notice', notice } });
|
|
175
|
+
}
|
|
138
176
|
async flushDigest() {
|
|
139
177
|
if (this.batch.length === 0)
|
|
140
178
|
return;
|
|
@@ -217,14 +255,18 @@ export class InboxChannel {
|
|
|
217
255
|
const line = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
218
256
|
if (!line)
|
|
219
257
|
continue;
|
|
220
|
-
let
|
|
258
|
+
let parsed;
|
|
221
259
|
try {
|
|
222
|
-
|
|
260
|
+
parsed = JSON.parse(line.slice(6));
|
|
223
261
|
}
|
|
224
262
|
catch {
|
|
225
263
|
continue;
|
|
226
264
|
}
|
|
227
|
-
|
|
265
|
+
if (parsed.notice) {
|
|
266
|
+
await this.deliverNotice(parsed.notice, parsed.content ?? '');
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
await this.deliver(parsed);
|
|
228
270
|
}
|
|
229
271
|
}
|
|
230
272
|
}
|
|
@@ -97,6 +97,24 @@ describe('InboxChannel', () => {
|
|
|
97
97
|
expect(channel.stats.reconnects).toBeGreaterThanOrEqual(1);
|
|
98
98
|
channel.close();
|
|
99
99
|
});
|
|
100
|
+
it('ORB-2151 - a server notice becomes its own event and never becomes the replay anchor', async () => {
|
|
101
|
+
const { mcp, notification } = mockMcp();
|
|
102
|
+
const hint = { notice: 'declare_scope', sessionId: 'cccccccc-0000-4000-8000-000000000000', content: 'declare your scope: orboto_session_start { scope: { role, projectKeys } }' };
|
|
103
|
+
const fetchFn = vi.fn()
|
|
104
|
+
.mockResolvedValueOnce(sse([hint, msg()]))
|
|
105
|
+
.mockImplementation(() => new Promise(() => { }));
|
|
106
|
+
const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, fetchFn, log: () => { }, digestMinutes: 0 });
|
|
107
|
+
channel.start();
|
|
108
|
+
await vi.waitFor(() => expect(notification).toHaveBeenCalledTimes(2));
|
|
109
|
+
const first = notification.mock.calls[0][0];
|
|
110
|
+
expect(first.params.meta).toEqual({ kind: 'notice', notice: 'declare_scope' });
|
|
111
|
+
expect(first.params.content).toContain('orboto_session_start');
|
|
112
|
+
expect(channel.stats).toMatchObject({ notices: 1, delivered: 1 });
|
|
113
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
114
|
+
await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2));
|
|
115
|
+
expect(String(fetchFn.mock.calls[1][0])).toContain('since=m1');
|
|
116
|
+
channel.close();
|
|
117
|
+
});
|
|
100
118
|
});
|
|
101
119
|
describe('server capability', () => {
|
|
102
120
|
async function connect(channel) {
|
package/dist/index.js
CHANGED
|
@@ -54,7 +54,7 @@ async function main() {
|
|
|
54
54
|
process.exit(1);
|
|
55
55
|
}
|
|
56
56
|
const channel = (process.env.ORBOTO_MCP_CHANNEL ?? '1') !== '0';
|
|
57
|
-
const server = await buildOrbotoMcpServer({ ...clientConfig, channel });
|
|
57
|
+
const server = await buildOrbotoMcpServer({ ...clientConfig, channel, instanceToken: (await import('./tools/shared.js')).mcpProcessInstance() });
|
|
58
58
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
59
59
|
const stdio = new StdioServerTransport();
|
|
60
60
|
await server.connect(stdio);
|
|
@@ -64,9 +64,16 @@ async function main() {
|
|
|
64
64
|
const instanceToken = mcpProcessInstance();
|
|
65
65
|
const inbox = new InboxChannel({ ...clientConfig, instanceToken, mcp: server, digestMinutes: digestMinutesFromEnv(process.env.ORBOTO_MCP_CHANNEL_DIGEST_MINUTES) });
|
|
66
66
|
inbox.start();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const { readClaudeContext } = await import('./claude-context.js');
|
|
68
|
+
const beat = () => {
|
|
69
|
+
const context = readClaudeContext(process.cwd());
|
|
70
|
+
preflightClient.post('/v1/agent/heartbeat', { clientInfo: { channel: 'claude-code', ...(context ?? {}) } }, { instanceToken })
|
|
71
|
+
.catch((err) => { console.error(`[orboto-mcp] channel heartbeat failed: ${err.message}`); });
|
|
72
|
+
};
|
|
73
|
+
beat();
|
|
74
|
+
const beats = setInterval(beat, 60_000);
|
|
75
|
+
beats.unref?.();
|
|
76
|
+
stdio.onclose = () => { clearInterval(beats); inbox.close(); };
|
|
70
77
|
}
|
|
71
78
|
return;
|
|
72
79
|
}
|
|
@@ -31,11 +31,19 @@ afterEach(() => { vi.restoreAllMocks(); });
|
|
|
31
31
|
* session scope schema, agent_notify the toSessionRef, and orboto_messages
|
|
32
32
|
* renders sender and receiver identities in its output schema (measured
|
|
33
33
|
* curated = 38,640, full = 168,029 chars, minimal = 3,140 tokens).
|
|
34
|
+
* ORB-2150 raised the curated ceiling to 39,700: `orboto_messages` gained
|
|
35
|
+
* the `dismiss` envelope and the per-message `dismissed` record, which is
|
|
36
|
+
* the surface that stops foreign mail from waking every session of a
|
|
37
|
+
* shared account (measured curated = 39,461 chars / 10,961 tokens).
|
|
38
|
+
* ORB-2149 raised the full ceiling to 171,200: `orboto_agent_wakes` puts
|
|
39
|
+
* the wake ledger and the delivery precision per session and per sender
|
|
40
|
+
* in the full toolset (measured full = 170,030 chars); the curated tier
|
|
41
|
+
* reaches it through the escape hatch and is unchanged.
|
|
34
42
|
*
|
|
35
43
|
* @see ORB-1805, ORB-1669, ORB-1910
|
|
36
44
|
*/
|
|
37
|
-
const CURATED_MAX_CHARS =
|
|
38
|
-
const FULL_MAX_CHARS =
|
|
45
|
+
const CURATED_MAX_CHARS = 39_700;
|
|
46
|
+
const FULL_MAX_CHARS = 171_200;
|
|
39
47
|
/**
|
|
40
48
|
* ORB-1805 - the estimator the ticket measured the failure with
|
|
41
49
|
* (`n_keep: 13533 >= n_ctx: 8192` in LM Studio): characters / 3.6. It is
|
|
@@ -56,7 +64,7 @@ const MINIMAL_MAX_TOKENS = 3_200;
|
|
|
56
64
|
* The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
|
|
57
65
|
* block has its own budget, enforced in instructions-budget.test.ts).
|
|
58
66
|
*/
|
|
59
|
-
const CURATED_SCHEMA_MAX_TOKENS =
|
|
67
|
+
const CURATED_SCHEMA_MAX_TOKENS = 11_000;
|
|
60
68
|
async function measuredManifest(toolset) {
|
|
61
69
|
const server = await buildOrbotoMcpServer({
|
|
62
70
|
baseUrl: 'https://orboto.example.com',
|
package/dist/orboto-client.js
CHANGED
|
@@ -22,6 +22,7 @@ export class OrbotoClient {
|
|
|
22
22
|
baseHeaders;
|
|
23
23
|
apiKey;
|
|
24
24
|
tokenProvider;
|
|
25
|
+
instanceToken;
|
|
25
26
|
constructor(config) {
|
|
26
27
|
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
27
28
|
if (!config.apiKey && !config.tokenProvider) {
|
|
@@ -29,6 +30,7 @@ export class OrbotoClient {
|
|
|
29
30
|
}
|
|
30
31
|
this.apiKey = config.apiKey;
|
|
31
32
|
this.tokenProvider = config.tokenProvider;
|
|
33
|
+
this.instanceToken = config.instanceToken;
|
|
32
34
|
const ua = config.userAgentSuffix
|
|
33
35
|
? `orboto-mcp/${VERSION} (${config.userAgentSuffix})`
|
|
34
36
|
: `orboto-mcp/${VERSION}`;
|
|
@@ -60,6 +62,7 @@ export class OrbotoClient {
|
|
|
60
62
|
const doFetch = async (token) => {
|
|
61
63
|
const headers = {
|
|
62
64
|
...this.baseHeaders,
|
|
65
|
+
...(this.instanceToken ? { 'x-orboto-agent-session': this.instanceToken } : {}),
|
|
63
66
|
...(accept ? { Accept: accept } : {}),
|
|
64
67
|
...(init.headers ?? {}),
|
|
65
68
|
Authorization: `Bearer ${token}`,
|
|
@@ -126,4 +126,19 @@ describe('preflightMcpSession', () => {
|
|
|
126
126
|
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
127
127
|
await expect(preflightMcpSession(client)).rejects.toBeInstanceOf(OrbotoApiError);
|
|
128
128
|
});
|
|
129
|
+
it('ORB-2151: a client built with an instance token names the session on every request; an explicit token wins', async () => {
|
|
130
|
+
const seen = [];
|
|
131
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
132
|
+
seen.push((init?.headers ?? {}));
|
|
133
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({}), text: async () => '' };
|
|
134
|
+
});
|
|
135
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', instanceToken: 'mcp-proc' });
|
|
136
|
+
await client.get('/users/me');
|
|
137
|
+
await client.post('/projects/p/tickets', { title: 't' });
|
|
138
|
+
await client.post('/v1/agent/heartbeat', {}, { instanceToken: 'mcp-explicit' });
|
|
139
|
+
expect(seen.map((h) => h['x-orboto-agent-session'])).toEqual(['mcp-proc', 'mcp-proc', 'mcp-explicit']);
|
|
140
|
+
const plain = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
141
|
+
await plain.get('/users/me');
|
|
142
|
+
expect(seen[3]['x-orboto-agent-session']).toBeUndefined();
|
|
143
|
+
});
|
|
129
144
|
});
|
package/dist/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { agentHeartbeatToolConfig, makeAgentHeartbeatHandler, agentPresenceToolC
|
|
|
26
26
|
import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/agent-coordination.js';
|
|
27
27
|
import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
|
|
28
28
|
import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
|
|
29
|
+
import { agentWakesToolConfig, makeAgentWakesHandler } from './tools/agent-wakes.js';
|
|
29
30
|
import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
|
|
30
31
|
import { getProjectToolConfig, makeGetProjectHandler } from './tools/get-project.js';
|
|
31
32
|
import { projectReadinessToolConfig, makeProjectReadinessHandler } from './tools/project-readiness.js';
|
|
@@ -188,7 +189,7 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
188
189
|
reg('orboto_draft_customer_reply', draftCustomerReplyToolConfig, makeDraftCustomerReplyHandler(client));
|
|
189
190
|
reg('orboto_embedding_status', embeddingStatusToolConfig, makeEmbeddingStatusHandler(client));
|
|
190
191
|
reg('orboto_ai_usage', aiUsageToolConfig, makeAiUsageHandler(client));
|
|
191
|
-
reg('orboto_session_start', sessionStartToolConfig, makeSessionStartHandler(client));
|
|
192
|
+
reg('orboto_session_start', sessionStartToolConfig, makeSessionStartHandler(client, { channel: opts.channel }));
|
|
192
193
|
reg('orboto_response_expand', responseExpandToolConfig, makeResponseExpandHandler());
|
|
193
194
|
reg('orboto_help', helpToolConfig, makeHelpHandler());
|
|
194
195
|
reg('orboto_api_search', apiSearchToolConfig, makeApiSearchHandler(client));
|
|
@@ -203,6 +204,7 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
203
204
|
reg('orboto_agent_notify', agentNotifyToolConfig, makeAgentNotifyHandler(client));
|
|
204
205
|
reg('orboto_message_work', agentMessageWorkToolConfig, makeAgentMessageWorkHandler(client));
|
|
205
206
|
reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
|
|
207
|
+
reg('orboto_agent_wakes', agentWakesToolConfig, makeAgentWakesHandler(client));
|
|
206
208
|
reg('orboto_agent_broadcast', agentBroadcastToolConfig, makeAgentBroadcastHandler(client));
|
|
207
209
|
reg('orboto_list_projects', listProjectsToolConfig, makeListProjectsHandler(client));
|
|
208
210
|
reg('orboto_get_project', getProjectToolConfig, makeGetProjectHandler(client));
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-1805 - the schema diet must never cost a PARAMETER.
|
|
3
|
+
*
|
|
4
|
+
* ORB-2157 - one reviewed baseline edit: `orboto_help.tool` is no longer
|
|
5
|
+
* required, because the tool now also answers `topic` ("connect" returns the
|
|
6
|
+
* setup script). The parameter itself is untouched and still the only way to
|
|
7
|
+
* ask for a tool's guidance; a call with neither says which topics exist.
|
|
3
8
|
*/
|
|
4
9
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
10
|
import { readFileSync } from 'node:fs';
|
|
@@ -149,7 +149,7 @@ describe('orboto_agent_notify', () => {
|
|
|
149
149
|
});
|
|
150
150
|
expect(result.content[0].text).toContain('notified bob@example.com');
|
|
151
151
|
expect(result.structuredContent).toMatchObject({ ok: true });
|
|
152
|
-
expect(capturedBody[0].senderRef).toMatch(/^
|
|
152
|
+
expect(capturedBody[0].senderRef).toMatch(/^agent-/);
|
|
153
153
|
});
|
|
154
154
|
it('ORB-1742: senderRef prefers the per-connection MCP session id, and an explicit ref wins outright', async () => {
|
|
155
155
|
const capturedBody = [];
|
|
@@ -13,7 +13,7 @@ import { mcpInstanceToken } from './shared.js';
|
|
|
13
13
|
const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
|
|
14
14
|
export const agentMessagesToolConfig = {
|
|
15
15
|
title: 'Agent inbox and message work',
|
|
16
|
-
description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). 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 (toSessionRef = from.sessionId reaches that instance).',
|
|
16
|
+
description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. dismiss is the third answer: not_mine hides it here and tells the sender, obsolete/duplicate close it for the account (note or duplicateOf required). Never leave mail you read and judged. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
messageWork: z.record(z.unknown()).optional()
|
|
19
19
|
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
|
|
@@ -22,6 +22,12 @@ export const agentMessagesToolConfig = {
|
|
|
22
22
|
project: z.string().min(1).max(64).optional().describe('Project key/UUID; includes unscoped mail.'),
|
|
23
23
|
includeOwnSends: z.boolean().default(false),
|
|
24
24
|
ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark read.'),
|
|
25
|
+
dismiss: z.object({
|
|
26
|
+
ids: z.array(z.string().uuid()).min(1).max(200),
|
|
27
|
+
reason: z.enum(['not_mine', 'obsolete', 'duplicate']),
|
|
28
|
+
note: z.string().max(500).optional(),
|
|
29
|
+
duplicateOf: z.string().uuid().optional(),
|
|
30
|
+
}).optional().describe('Dismiss with a reason; obsolete/duplicate need note or duplicateOf.'),
|
|
25
31
|
}).shape,
|
|
26
32
|
outputSchema: z.object({
|
|
27
33
|
work: z.record(z.unknown()).optional(),
|
|
@@ -38,8 +44,13 @@ export const agentMessagesToolConfig = {
|
|
|
38
44
|
projectKey: z.string().nullable(),
|
|
39
45
|
createdAt: z.string(),
|
|
40
46
|
readAt: z.string().nullable(),
|
|
47
|
+
dismissed: z.object({
|
|
48
|
+
reason: z.string(), note: z.string().nullable(), duplicateOf: z.string().nullable(), createdAt: z.string(),
|
|
49
|
+
}).nullable(),
|
|
41
50
|
})),
|
|
42
51
|
acked: z.number().int(),
|
|
52
|
+
dismissed: z.number().int().optional(),
|
|
53
|
+
closed: z.number().int().optional(),
|
|
43
54
|
}).shape,
|
|
44
55
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
45
56
|
};
|
|
@@ -51,6 +62,18 @@ export function makeAgentMessagesHandler(client) {
|
|
|
51
62
|
const result = await makeAgentMessageWorkHandler(client)(args.messageWork, extra);
|
|
52
63
|
return { content: result.content, structuredContent: { messages: [], acked: 0, work: result.structuredContent } };
|
|
53
64
|
}
|
|
65
|
+
let dismissedCount = 0;
|
|
66
|
+
let closedCount = 0;
|
|
67
|
+
let feedbackSent = 0;
|
|
68
|
+
if (args.dismiss) {
|
|
69
|
+
const res = await client.post('/v1/agent/messages/dismiss', {
|
|
70
|
+
...args.dismiss,
|
|
71
|
+
instanceToken: mcpInstanceToken(undefined, extra),
|
|
72
|
+
});
|
|
73
|
+
dismissedCount = res.dismissed;
|
|
74
|
+
closedCount = res.closed;
|
|
75
|
+
feedbackSent = res.feedbackSent;
|
|
76
|
+
}
|
|
54
77
|
let acked = 0;
|
|
55
78
|
if (args.ackIds && args.ackIds.length > 0) {
|
|
56
79
|
const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
|
|
@@ -73,6 +96,9 @@ export function makeAgentMessagesHandler(client) {
|
|
|
73
96
|
if (messages.length > 0) {
|
|
74
97
|
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).`);
|
|
75
98
|
}
|
|
99
|
+
if (args.dismiss) {
|
|
100
|
+
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` : ''}.`);
|
|
101
|
+
}
|
|
76
102
|
if (sessionId)
|
|
77
103
|
lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
|
|
78
104
|
return {
|
|
@@ -82,9 +108,11 @@ export function makeAgentMessagesHandler(client) {
|
|
|
82
108
|
messages: messages.map((m) => ({
|
|
83
109
|
id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
|
|
84
110
|
payload: m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
|
|
85
|
-
createdAt: m.createdAt, readAt: m.readAt,
|
|
111
|
+
createdAt: m.createdAt, readAt: m.readAt, dismissed: m.dismissed ?? null,
|
|
86
112
|
})),
|
|
87
113
|
acked,
|
|
114
|
+
dismissed: dismissedCount,
|
|
115
|
+
closed: closedCount,
|
|
88
116
|
},
|
|
89
117
|
};
|
|
90
118
|
};
|
|
@@ -34,6 +34,17 @@ export const AgentInventoryEntrySchema = z.object({
|
|
|
34
34
|
scope: AgentSessionScopeSchema.nullable(),
|
|
35
35
|
/** ORB-2136 - unread inbox messages this session would list. */
|
|
36
36
|
unreadMessages: z.number().int(),
|
|
37
|
+
/**
|
|
38
|
+
* ORB-2149 - the last 24 hours of wakes: how many deliveries, how precise,
|
|
39
|
+
* what the wrong ones cost. ORB-2170 - `backlogSeen` is the `nudge` count
|
|
40
|
+
* over the same window, a backlog sighting rather than a wake.
|
|
41
|
+
*/
|
|
42
|
+
wakes: z.object({
|
|
43
|
+
count: z.number().int(),
|
|
44
|
+
precision: z.number().nullable(),
|
|
45
|
+
wrongWakeContextTokens: z.number().int(),
|
|
46
|
+
backlogSeen: z.number().int(),
|
|
47
|
+
}),
|
|
37
48
|
lastSeenAt: z.string(),
|
|
38
49
|
createdAt: z.string(),
|
|
39
50
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2149 - the wake ledger: what woke this session, by which rule, what it
|
|
3
|
+
* did with it and what it cost.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
export const agentWakesToolConfig = {
|
|
7
|
+
title: 'Wake ledger',
|
|
8
|
+
description: 'Who woke this account\'s sessions, which visibility rule 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
|
+
inputSchema: z.object({
|
|
10
|
+
stats: z.boolean().default(false).describe('Precision per session and per sender instead of rows.'),
|
|
11
|
+
hours: z.number().int().min(1).max(720).optional().describe('Stats window, default 24.'),
|
|
12
|
+
sessionId: z.string().uuid().optional().describe('Narrow to one woken session.'),
|
|
13
|
+
project: z.string().min(1).max(64).optional().describe('Project key or UUID.'),
|
|
14
|
+
outcome: z.enum(['pending', 'handled', 'not_mine', 'obsolete', 'duplicate', 'expired']).optional(),
|
|
15
|
+
limit: z.number().int().min(1).max(200).optional().describe('Default: 50.'),
|
|
16
|
+
cursor: z.string().optional().describe('Opaque cursor from a previous response.'),
|
|
17
|
+
}).shape,
|
|
18
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
19
|
+
};
|
|
20
|
+
function statsLine(e) {
|
|
21
|
+
const precision = e.precision === null ? 'unjudged' : `${Math.round(e.precision * 100)}%`;
|
|
22
|
+
const tokens = e.wrongWakeContextTokens > 0 ? `, wrong wakes cost ${e.wrongWakeContextTokens} tokens` : '';
|
|
23
|
+
const backlog = e.backlogSeen > 0 ? `, ${e.backlogSeen} backlog seen` : '';
|
|
24
|
+
return `- ${e.label}: ${e.wakes} wakes, precision ${precision}, median ${e.medianLatencyMs ?? '?'} ms, top rule ${e.topRule ?? '-'}${tokens}${backlog}`;
|
|
25
|
+
}
|
|
26
|
+
export function makeAgentWakesHandler(client) {
|
|
27
|
+
return async (args) => {
|
|
28
|
+
const qs = new URLSearchParams();
|
|
29
|
+
if (args.sessionId)
|
|
30
|
+
qs.set('sessionId', args.sessionId);
|
|
31
|
+
if (args.stats) {
|
|
32
|
+
if (args.hours)
|
|
33
|
+
qs.set('hours', String(args.hours));
|
|
34
|
+
const res = await client.get(`/admin/agents/wake-stats${qs.toString() ? `?${qs.toString()}` : ''}`);
|
|
35
|
+
const text = [
|
|
36
|
+
`Last ${res.hours} h - woken sessions:`,
|
|
37
|
+
...(res.sessions.length ? res.sessions.map(statsLine) : ['- (no wakes)']),
|
|
38
|
+
'Senders:',
|
|
39
|
+
...(res.senders.length ? res.senders.map(statsLine) : ['- (none)']),
|
|
40
|
+
].join('\n');
|
|
41
|
+
return { content: [{ type: 'text', text }], structuredContent: res };
|
|
42
|
+
}
|
|
43
|
+
if (args.project)
|
|
44
|
+
qs.set('project', args.project);
|
|
45
|
+
if (args.outcome)
|
|
46
|
+
qs.set('outcome', args.outcome);
|
|
47
|
+
if (args.limit)
|
|
48
|
+
qs.set('limit', String(args.limit));
|
|
49
|
+
if (args.cursor)
|
|
50
|
+
qs.set('cursor', args.cursor);
|
|
51
|
+
const res = await client.get(`/v1/agent/wakes${qs.toString() ? `?${qs.toString()}` : ''}`);
|
|
52
|
+
const lines = res.rows.length === 0
|
|
53
|
+
? '(no wakes recorded)'
|
|
54
|
+
: res.rows.map((r) => `- ${r.wokenAt} ${r.subject} - from ${r.senderLabel} via ${r.rule}/${r.transport} -> ${r.outcome}`
|
|
55
|
+
+ `${r.latencyMs === null ? '' : ` (${r.latencyMs} ms`}${r.contextTokens === null ? r.latencyMs === null ? '' : ')' : `, ${r.contextTokens} ctx tokens)`}`).join('\n');
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: 'text', text: lines + (res.nextCursor ? `\n\n(next cursor: ${res.nextCursor})` : '') }],
|
|
58
|
+
structuredContent: res,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -109,6 +109,6 @@ describe('orboto_api_call', () => {
|
|
|
109
109
|
await makeApiCallHandler(client)({ method: 'POST', path: '/v1/agent/heartbeat', body: { scope: { role: 'spec' } } }, { sessionId: 'abc123' });
|
|
110
110
|
expect(seen[0]['x-orboto-agent-session']).toBe('mcp-abc123');
|
|
111
111
|
await makeApiCallHandler(client)({ method: 'GET', path: '/v1/agent/messages' });
|
|
112
|
-
expect(seen[1]['x-orboto-agent-session']).toMatch(/^
|
|
112
|
+
expect(seen[1]['x-orboto-agent-session']).toMatch(/^agent-/);
|
|
113
113
|
});
|
|
114
114
|
});
|
package/dist/tools/docs.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function resolveDocId(client, docIdOrKey) {
|
|
|
42
42
|
}
|
|
43
43
|
export const getDocToolConfig = {
|
|
44
44
|
title: 'Get a doc by id or key',
|
|
45
|
-
description: 'Return the doc content (Markdown) plus backlinks (other docs that reference this doc, or tickets/milestones the doc is linked from). Accepts either the doc UUID or its human-readable key (e.g. ORB-D12 / DOC-5).',
|
|
45
|
+
description: 'Return the doc content (Markdown) plus backlinks (other docs that reference this doc, or tickets/milestones the doc is linked from). Accepts either the doc UUID or its human-readable key (e.g. ORB-D12 / DOC-5). A UUID read also reports `embedding.state` - embedded | pending | stale | not_embeddable - so a caller waits for semantic search instead of guessing (ORB-2154).',
|
|
46
46
|
inputSchema: z.object({
|
|
47
47
|
docId: z.string().min(1).describe('Doc UUID or human-readable key (ORB-D12 / DOC-5). Discover via orboto_list_doc_spaces or orboto_search.'),
|
|
48
48
|
}).shape,
|
|
@@ -56,7 +56,7 @@ export function makeGetDocHandler(client) {
|
|
|
56
56
|
const backlinks = await client.get(`/docs/${doc.id}/backlinks`).catch(() => []);
|
|
57
57
|
const lines = [
|
|
58
58
|
`# ${doc.icon ? `${doc.icon} ` : ''}${doc.title}`,
|
|
59
|
-
`${doc.docKey ? `Key: ${doc.docKey} · ` : ''}ID: ${doc.id} · Visibility: ${doc.visibility} · Updated: ${doc.updatedAt}`,
|
|
59
|
+
`${doc.docKey ? `Key: ${doc.docKey} · ` : ''}ID: ${doc.id} · Visibility: ${doc.visibility} · Updated: ${doc.updatedAt}${doc.embedding ? ` · Embedding: ${doc.embedding.state}` : ''}`,
|
|
60
60
|
'',
|
|
61
61
|
doc.content || '_(empty)_',
|
|
62
62
|
];
|
|
@@ -80,6 +80,7 @@ export function makeGetDocHandler(client) {
|
|
|
80
80
|
parentDocId: doc.parentDocId,
|
|
81
81
|
spaceId: doc.spaceId,
|
|
82
82
|
slug: doc.slug,
|
|
83
|
+
embedding: doc.embedding ?? null,
|
|
83
84
|
},
|
|
84
85
|
backlinks: backlinks.map((b) => ({
|
|
85
86
|
sourceDocId: b.sourceDocId,
|
package/dist/tools/help.js
CHANGED
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { getToolDoc, listToolDocNames } from '../tool-docs.js';
|
|
12
|
+
import { connectScriptText } from '../connect-script.js';
|
|
13
|
+
import { onboardingText } from '../agent-head.js';
|
|
14
|
+
/** ORB-2157, ORB-2161 - guidance that belongs to no single tool, addressed by topic. */
|
|
15
|
+
const TOPICS = { connect: connectScriptText, onboarding: onboardingText };
|
|
12
16
|
export const helpToolConfig = {
|
|
13
17
|
title: 'Full guidance for one orboto tool',
|
|
14
|
-
description: 'Return the complete guidance text (workflows, warnings, edge cases) for one orboto tool by name - manifest descriptions are one-line summaries, this is the rest. Call it before first use of an unfamiliar write tool.',
|
|
18
|
+
description: 'Return the complete guidance text (workflows, warnings, edge cases) for one orboto tool by name - manifest descriptions are one-line summaries, this is the rest. Call it before first use of an unfamiliar write tool. topic: "connect" returns the step-by-step script for setting a person up, topic: "onboarding" the first five minutes and the work loop.',
|
|
15
19
|
inputSchema: z.object({
|
|
16
|
-
tool: z.string().min(1).max(128).describe('Tool name, e.g. orboto_create_ticket.'),
|
|
20
|
+
tool: z.string().min(1).max(128).optional().describe('Tool name, e.g. orboto_create_ticket.'),
|
|
21
|
+
topic: z.enum(['connect', 'onboarding']).optional().describe('Guidance topic instead of a tool.'),
|
|
17
22
|
}).shape,
|
|
18
23
|
outputSchema: z.object({
|
|
19
24
|
tool: z.string(),
|
|
@@ -23,7 +28,19 @@ export const helpToolConfig = {
|
|
|
23
28
|
};
|
|
24
29
|
export function makeHelpHandler() {
|
|
25
30
|
return async (args) => {
|
|
26
|
-
const
|
|
31
|
+
const topic = args.topic?.trim();
|
|
32
|
+
if (topic) {
|
|
33
|
+
const render = TOPICS[topic];
|
|
34
|
+
const text = render
|
|
35
|
+
? render()
|
|
36
|
+
: `No guidance registered for topic "${topic}". Known topics: ${Object.keys(TOPICS).join(', ')}`;
|
|
37
|
+
return { content: [{ type: 'text', text }], structuredContent: { tool: `topic:${topic}`, guidance: render ? text : '' } };
|
|
38
|
+
}
|
|
39
|
+
const name = (args.tool ?? '').trim();
|
|
40
|
+
if (!name) {
|
|
41
|
+
const text = `Name a tool, or a topic: ${Object.keys(TOPICS).join(', ')}.`;
|
|
42
|
+
return { content: [{ type: 'text', text }], structuredContent: { tool: '', guidance: '' } };
|
|
43
|
+
}
|
|
27
44
|
const doc = getToolDoc(name) ?? getToolDoc(`orboto_${name}`);
|
|
28
45
|
if (!doc) {
|
|
29
46
|
const known = listToolDocNames();
|
|
@@ -14,6 +14,8 @@ import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
|
14
14
|
import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
|
|
15
15
|
import { loadRequiredRules } from '../required-rules.js';
|
|
16
16
|
import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
|
|
17
|
+
import { channelStartLines } from '../inbox-channel.js';
|
|
18
|
+
import { agentHeadText } from '../agent-head.js';
|
|
17
19
|
export const sessionStartToolConfig = {
|
|
18
20
|
title: 'Load the rules you must follow + re-orient',
|
|
19
21
|
description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Registers this session (a heartbeat on its instance row) and is otherwise read-only. '
|
|
@@ -128,7 +130,7 @@ async function buildTicketBundle(client, ticketKey) {
|
|
|
128
130
|
},
|
|
129
131
|
};
|
|
130
132
|
}
|
|
131
|
-
export function makeSessionStartHandler(client) {
|
|
133
|
+
export function makeSessionStartHandler(client, opts = {}) {
|
|
132
134
|
let lastKnownRulesHash;
|
|
133
135
|
return async (input = {}, extra) => {
|
|
134
136
|
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
@@ -202,7 +204,7 @@ export function makeSessionStartHandler(client) {
|
|
|
202
204
|
? tickets.filter((t) => !t.projectId || t.projectId === scopeProjectId)
|
|
203
205
|
: tickets;
|
|
204
206
|
const elsewhereCount = tickets.length - scopedTickets.length;
|
|
205
|
-
const lines = ['# orboto session start'];
|
|
207
|
+
const lines = [agentHeadText(), '', '# orboto session start'];
|
|
206
208
|
if (me)
|
|
207
209
|
lines.push(`You are ${me.fullName ?? me.email}${me.email ? ` (${me.email})` : ''}.`);
|
|
208
210
|
if (me?.workspaceLocale || me?.locale)
|
|
@@ -256,6 +258,8 @@ export function makeSessionStartHandler(client) {
|
|
|
256
258
|
? [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('; ')
|
|
257
259
|
: 'no scope declared - this session lists every account-addressed message; pass scope: { projectKeys, role } to narrow it';
|
|
258
260
|
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}" }.`);
|
|
261
|
+
if (opts.channel)
|
|
262
|
+
lines.push('', ...channelStartLines());
|
|
259
263
|
if (pendingMessages.length > 0) {
|
|
260
264
|
lines.push('', '## Agent messages - unread');
|
|
261
265
|
for (const m of pendingMessages)
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { OrbotoClient } from '../orboto-client.js';
|
|
11
11
|
import { makeSessionStartHandler } from './session-start.js';
|
|
12
|
+
import { CHANNEL_START_COMMANDS, CHANNEL_START_COMMANDS_WITHOUT_CLI } from '../inbox-channel.js';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
12
14
|
import { makeResponseExpandHandler } from './response-expand.js';
|
|
13
15
|
import { applyResponseBudget, budgetFor, DEFAULT_BUDGET_CHARS } from '../response-budget.js';
|
|
14
16
|
beforeEach(() => { vi.restoreAllMocks(); });
|
|
@@ -566,4 +568,35 @@ describe('ORB-2136 - session scope and ref', () => {
|
|
|
566
568
|
expect(seen.find((c) => c.path === '/v1/agent/heartbeat')?.body).toEqual({});
|
|
567
569
|
expect(res.content[0].text).toContain('no scope declared');
|
|
568
570
|
});
|
|
571
|
+
it('ORB-2148: a stdio proxy with the channel on hands over the exact start commands; other servers stay silent', async () => {
|
|
572
|
+
const fixture = {
|
|
573
|
+
'/users/me/assigned-tickets': { items: [] },
|
|
574
|
+
'/users/me': { email: 'dev@x.io', fullName: 'Dev' },
|
|
575
|
+
'/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
|
|
576
|
+
'/time/timer': {},
|
|
577
|
+
};
|
|
578
|
+
stubByPath(fixture);
|
|
579
|
+
const on = (await makeSessionStartHandler(client, { channel: true })()).content[0].text;
|
|
580
|
+
expect(on).toContain('## Wake channel');
|
|
581
|
+
for (const cmd of Object.values(CHANNEL_START_COMMANDS))
|
|
582
|
+
expect(on).toContain(`\`${cmd}\``);
|
|
583
|
+
for (const cmd of Object.values(CHANNEL_START_COMMANDS_WITHOUT_CLI))
|
|
584
|
+
expect(on).toContain(`\`${cmd}\``);
|
|
585
|
+
expect(on).toContain('orboto claude --continue');
|
|
586
|
+
expect(on).toContain('orboto claude --resume <session id>');
|
|
587
|
+
expect(on).toContain('orboto mcp install');
|
|
588
|
+
expect(on).toContain('local development');
|
|
589
|
+
stubByPath(fixture);
|
|
590
|
+
const off = (await makeSessionStartHandler(client)()).content[0].text;
|
|
591
|
+
expect(off).not.toContain('## Wake channel');
|
|
592
|
+
expect(off).not.toContain('--dangerously-load-development-channels');
|
|
593
|
+
});
|
|
594
|
+
it('ORB-2148: docs/claude-code.md carries both command forms', () => {
|
|
595
|
+
const docs = readFileSync(new URL('../../../../docs/claude-code.md', import.meta.url), 'utf8');
|
|
596
|
+
for (const cmd of Object.values(CHANNEL_START_COMMANDS))
|
|
597
|
+
expect(docs).toContain(cmd);
|
|
598
|
+
for (const cmd of Object.values(CHANNEL_START_COMMANDS_WITHOUT_CLI))
|
|
599
|
+
expect(docs).toContain(cmd);
|
|
600
|
+
expect(docs).toContain('orboto mcp install');
|
|
601
|
+
});
|
|
569
602
|
});
|
package/dist/tools/shared.js
CHANGED
|
@@ -95,8 +95,14 @@ export function ticketLine(t) {
|
|
|
95
95
|
parts.push('[waiting on Git ingestion]');
|
|
96
96
|
return parts.join(' ');
|
|
97
97
|
}
|
|
98
|
-
import {
|
|
99
|
-
|
|
98
|
+
import { createHash } from 'node:crypto';
|
|
99
|
+
import { hostname } from 'node:os';
|
|
100
|
+
/** ORB-2171 - the checkout's token, derived exactly like the CLI and the skill wrapper derive theirs; `ORBOTO_AGENT_SESSION` wins. */
|
|
101
|
+
export function checkoutInstanceToken(env = process.env, host = hostname(), cwd = process.cwd()) {
|
|
102
|
+
const explicit = (env.ORBOTO_AGENT_SESSION ?? '').trim();
|
|
103
|
+
return explicit || `agent-${createHash('sha256').update(`${host}:${cwd}`).digest('hex').slice(0, 12)}`;
|
|
104
|
+
}
|
|
105
|
+
const MCP_PROCESS_INSTANCE = checkoutInstanceToken();
|
|
100
106
|
/** Precedence: explicit caller-supplied token > per-connection MCP session id
|
|
101
107
|
* (distinct per client even on a shared HTTP server) > per-process id (stdio). */
|
|
102
108
|
export function mcpInstanceToken(explicit, extra) {
|
|
@@ -163,3 +163,11 @@ describe('resolveByName', () => {
|
|
|
163
163
|
expect(ambiguous?.map((r) => r.id).sort()).toEqual(['m1', 'm3']);
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
|
+
describe('ORB-2171 - the stdio proxy shares the checkout token with the CLI and the skill wrapper', () => {
|
|
167
|
+
it('ORBOTO_AGENT_SESSION wins; otherwise agent-<sha256(hostname:cwd)[:12]>', async () => {
|
|
168
|
+
const { checkoutInstanceToken } = await import('./shared.js');
|
|
169
|
+
expect(checkoutInstanceToken({ ORBOTO_AGENT_SESSION: ' lane-7 ' }, 'host', '/repo')).toBe('lane-7');
|
|
170
|
+
expect(checkoutInstanceToken({}, 'build-host', '/srv/checkout')).toBe('agent-e0b18afc9cec');
|
|
171
|
+
expect(checkoutInstanceToken({}, 'build-host', '/srv/other')).not.toBe('agent-e0b18afc9cec');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -118,7 +118,7 @@ describe('orboto_work_next', () => {
|
|
|
118
118
|
expect(calls[0].method).toBe('POST');
|
|
119
119
|
expect(calls[0].url).toContain('/work-sessions/next');
|
|
120
120
|
expect(calls[0].body?.projectKey).toBe('ACME');
|
|
121
|
-
expect(String(calls[0].body?.agentSessionToken)).toMatch(/^
|
|
121
|
+
expect(String(calls[0].body?.agentSessionToken)).toMatch(/^agent-/);
|
|
122
122
|
const text = res.content[0].text;
|
|
123
123
|
expect(text).toContain('Reserved ACME-42');
|
|
124
124
|
expect(text).toContain('ws1');
|
|
@@ -89,7 +89,7 @@ describe('orboto_work_start', () => {
|
|
|
89
89
|
expect(post.method).toBe('POST');
|
|
90
90
|
expect(post.url).toContain('/work-sessions/start');
|
|
91
91
|
expect(post.body?.ticketId).toBe('t1');
|
|
92
|
-
expect(String(post.body?.agentSessionToken)).toMatch(/^
|
|
92
|
+
expect(String(post.body?.agentSessionToken)).toMatch(/^agent-/);
|
|
93
93
|
const text = res.content[0].text;
|
|
94
94
|
expect(text).toContain('Started a implementation work session on ACME-42');
|
|
95
95
|
expect(text).toContain('ws1');
|
|
@@ -58,7 +58,7 @@ describe('orboto_work_session_start', () => {
|
|
|
58
58
|
expect(post.method).toBe('POST');
|
|
59
59
|
expect(post.url).toContain('/work-sessions');
|
|
60
60
|
expect(post.body?.ticketId).toBe('t1');
|
|
61
|
-
expect(String(post.body?.agentSessionToken)).toMatch(/^
|
|
61
|
+
expect(String(post.body?.agentSessionToken)).toMatch(/^agent-/);
|
|
62
62
|
const text = res.content[0].text;
|
|
63
63
|
expect(text).toContain('Started a implementation work session on ACME-42');
|
|
64
64
|
expect(text).toContain('ws1');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.200.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",
|