loreli 0.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +710 -97
- package/bin/loreli.js +89 -0
- package/package.json +77 -14
- package/packages/README.md +101 -0
- package/packages/action/README.md +98 -0
- package/packages/action/prompts/action.md +172 -0
- package/packages/action/src/index.js +684 -0
- package/packages/agent/README.md +606 -0
- package/packages/agent/src/backends/claude.js +387 -0
- package/packages/agent/src/backends/codex.js +351 -0
- package/packages/agent/src/backends/cursor.js +371 -0
- package/packages/agent/src/backends/index.js +486 -0
- package/packages/agent/src/base.js +138 -0
- package/packages/agent/src/cli.js +275 -0
- package/packages/agent/src/discover.js +396 -0
- package/packages/agent/src/factory.js +124 -0
- package/packages/agent/src/index.js +12 -0
- package/packages/agent/src/models.js +159 -0
- package/packages/agent/src/output.js +62 -0
- package/packages/agent/src/session.js +162 -0
- package/packages/agent/src/trace.js +186 -0
- package/packages/classify/README.md +136 -0
- package/packages/classify/prompts/blocker.md +12 -0
- package/packages/classify/prompts/feedback.md +14 -0
- package/packages/classify/prompts/pane-state.md +20 -0
- package/packages/classify/src/index.js +81 -0
- package/packages/config/README.md +898 -0
- package/packages/config/src/defaults.js +145 -0
- package/packages/config/src/index.js +223 -0
- package/packages/config/src/schema.js +291 -0
- package/packages/config/src/validate.js +160 -0
- package/packages/context/README.md +165 -0
- package/packages/context/src/index.js +198 -0
- package/packages/hub/README.md +338 -0
- package/packages/hub/src/base.js +154 -0
- package/packages/hub/src/github.js +1597 -0
- package/packages/hub/src/index.js +79 -0
- package/packages/hub/src/labels.js +48 -0
- package/packages/identity/README.md +288 -0
- package/packages/identity/src/index.js +620 -0
- package/packages/identity/src/themes/avatar.js +217 -0
- package/packages/identity/src/themes/digimon.js +217 -0
- package/packages/identity/src/themes/dragonball.js +217 -0
- package/packages/identity/src/themes/lotr.js +217 -0
- package/packages/identity/src/themes/marvel.js +217 -0
- package/packages/identity/src/themes/pokemon.js +217 -0
- package/packages/identity/src/themes/starwars.js +217 -0
- package/packages/identity/src/themes/transformers.js +217 -0
- package/packages/identity/src/themes/zelda.js +217 -0
- package/packages/knowledge/README.md +217 -0
- package/packages/knowledge/src/index.js +243 -0
- package/packages/log/README.md +93 -0
- package/packages/log/src/index.js +252 -0
- package/packages/marker/README.md +200 -0
- package/packages/marker/src/index.js +184 -0
- package/packages/mcp/README.md +323 -0
- package/packages/mcp/instructions.md +126 -0
- package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
- package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
- package/packages/mcp/scaffolding/loreli.yml +491 -0
- package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
- package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
- package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
- package/packages/mcp/scaffolding/pull-request.md +23 -0
- package/packages/mcp/src/index.js +600 -0
- package/packages/mcp/src/tools/agent-context.js +44 -0
- package/packages/mcp/src/tools/agents.js +450 -0
- package/packages/mcp/src/tools/context.js +200 -0
- package/packages/mcp/src/tools/github.js +1163 -0
- package/packages/mcp/src/tools/hitl.js +162 -0
- package/packages/mcp/src/tools/index.js +18 -0
- package/packages/mcp/src/tools/refactor.js +227 -0
- package/packages/mcp/src/tools/repo.js +44 -0
- package/packages/mcp/src/tools/start.js +904 -0
- package/packages/mcp/src/tools/status.js +149 -0
- package/packages/mcp/src/tools/work.js +134 -0
- package/packages/orchestrator/README.md +192 -0
- package/packages/orchestrator/src/index.js +1492 -0
- package/packages/planner/README.md +251 -0
- package/packages/planner/prompts/plan-reviewer.md +109 -0
- package/packages/planner/prompts/planner.md +191 -0
- package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
- package/packages/planner/src/index.js +1381 -0
- package/packages/review/README.md +129 -0
- package/packages/review/prompts/reviewer.md +158 -0
- package/packages/review/src/index.js +1403 -0
- package/packages/risk/README.md +178 -0
- package/packages/risk/prompts/risk.md +272 -0
- package/packages/risk/src/index.js +439 -0
- package/packages/session/README.md +165 -0
- package/packages/session/src/index.js +215 -0
- package/packages/test-utils/README.md +96 -0
- package/packages/test-utils/src/index.js +354 -0
- package/packages/tmux/README.md +261 -0
- package/packages/tmux/src/index.js +501 -0
- package/packages/workflow/README.md +317 -0
- package/packages/workflow/prompts/preamble.md +14 -0
- package/packages/workflow/src/index.js +660 -0
- package/packages/workflow/src/proof-of-life.js +74 -0
- package/packages/workspace/README.md +143 -0
- package/packages/workspace/src/index.js +1127 -0
- package/index.js +0 -8
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import {
|
|
3
|
+
ListToolsRequestSchema,
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListPromptsRequestSchema,
|
|
6
|
+
GetPromptRequestSchema,
|
|
7
|
+
ListResourcesRequestSchema,
|
|
8
|
+
ReadResourceRequestSchema
|
|
9
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { allTools } from './tools/index.js';
|
|
15
|
+
import { Identity, Registry, side, infer, council, pick } from 'loreli/identity';
|
|
16
|
+
import { BackendRegistry } from 'loreli/agent';
|
|
17
|
+
import { Storage } from 'loreli/session';
|
|
18
|
+
import { Orchestrator } from 'loreli/orchestrator';
|
|
19
|
+
import { PlannerWorkflow } from 'loreli/planner';
|
|
20
|
+
import { ActionWorkflow } from 'loreli/action';
|
|
21
|
+
import { RiskWorkflow } from 'loreli/risk';
|
|
22
|
+
import { ReviewWorkflow } from 'loreli/review';
|
|
23
|
+
import { Config } from 'loreli/config';
|
|
24
|
+
import { logger } from 'loreli/log';
|
|
25
|
+
|
|
26
|
+
const log = logger('mcp');
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Empty JSON Schema used when a tool defines no input parameters.
|
|
32
|
+
*
|
|
33
|
+
* @type {object}
|
|
34
|
+
*/
|
|
35
|
+
const EMPTY_SCHEMA = { type: 'object', properties: {} };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tools that agent MCP sessions are allowed to call.
|
|
39
|
+
*
|
|
40
|
+
* Agent subprocesses (spawned CLI backends) share the same MCP server
|
|
41
|
+
* code but must NOT access orchestration tools like add_agent or
|
|
42
|
+
* start_work. Exposing those tools leads to confused agents calling
|
|
43
|
+
* them, which destroys workspaces and starts duplicate reactor loops.
|
|
44
|
+
*
|
|
45
|
+
* `start` was previously included so agents would receive a no-op
|
|
46
|
+
* response ("already configured") instead of an error. However, when
|
|
47
|
+
* agent hydration fails or env vars are missing, the no-op guard
|
|
48
|
+
* (`ctx.sessionId && ctx.agentName`) doesn't fire and the call falls
|
|
49
|
+
* through to the real implementation — which reaps the tmux session,
|
|
50
|
+
* killing every running agent. Observed in E2E: a Cursor Agent called
|
|
51
|
+
* `start` on its own repo, destroying the session it was living in.
|
|
52
|
+
* Safer to block it entirely and return a clean "not available" error.
|
|
53
|
+
*
|
|
54
|
+
* @type {Set<string>}
|
|
55
|
+
*/
|
|
56
|
+
const AGENT_TOOLS = new Set(['plan', 'pr', 'comment', 'read', 'environment', 'context', 'refactor']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Loreli MCP server for agentic team orchestration.
|
|
60
|
+
*
|
|
61
|
+
* Uses the low-level MCP SDK Server instead of McpServer because our
|
|
62
|
+
* tool definitions use plain JSON Schema, not Zod. The low-level API
|
|
63
|
+
* gives us full control over schema serialization and argument passing.
|
|
64
|
+
*
|
|
65
|
+
* Follows the uprising start pattern: constructor creates the
|
|
66
|
+
* Server, _prepare() registers tools/resources/prompts, start()
|
|
67
|
+
* connects a transport.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```js
|
|
71
|
+
* const loreli = new Loreli();
|
|
72
|
+
* await loreli.start();
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export class Loreli {
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} [configuration] - Server configuration.
|
|
78
|
+
*/
|
|
79
|
+
constructor(configuration = {}) {
|
|
80
|
+
/** @type {object} Server configuration. */
|
|
81
|
+
this.config = configuration;
|
|
82
|
+
|
|
83
|
+
const instructions = this.loadInstructions();
|
|
84
|
+
|
|
85
|
+
/** @type {Server} The low-level MCP server instance. */
|
|
86
|
+
this.server = new Server(
|
|
87
|
+
{ name: 'loreli', version: '0.0.0' },
|
|
88
|
+
{
|
|
89
|
+
capabilities: { tools: {}, prompts: {}, resources: {} },
|
|
90
|
+
...(instructions ? { instructions } : {})
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
/** @type {object} Shared runtime context injected into every tool handler. */
|
|
95
|
+
this.ctx = null;
|
|
96
|
+
|
|
97
|
+
/** @type {Record<string, object>} Registered tool definitions. */
|
|
98
|
+
this._tools = {};
|
|
99
|
+
|
|
100
|
+
/** @type {Promise<void>} Prepared promise. */
|
|
101
|
+
this.prepared = this._prepare();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load instructions from the instructions.md file.
|
|
106
|
+
*
|
|
107
|
+
* @returns {string|null} Instructions text, or null if file not found.
|
|
108
|
+
*/
|
|
109
|
+
loadInstructions() {
|
|
110
|
+
try {
|
|
111
|
+
return readFileSync(join(__dirname, '..', 'instructions.md'), 'utf8');
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Dynamically update a tool's description and schema after runtime
|
|
119
|
+
* discovery (e.g. available backends, models). Emits a
|
|
120
|
+
* `tools/list_changed` notification so MCP clients refresh.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} name - Tool name to update.
|
|
123
|
+
* @param {object} updates - Properties to merge (description, schema).
|
|
124
|
+
*/
|
|
125
|
+
updateTool(name, updates) {
|
|
126
|
+
const tool = this._tools[name];
|
|
127
|
+
if (!tool) return;
|
|
128
|
+
|
|
129
|
+
if (updates.description) tool.description = updates.description;
|
|
130
|
+
if (updates.schema) tool.schema = updates.schema;
|
|
131
|
+
|
|
132
|
+
// Notify MCP clients that the tool list has changed
|
|
133
|
+
this.server.sendToolListChanged?.();
|
|
134
|
+
log.debug(`tool updated: ${name}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Register tool definitions and set up the tools/list and tools/call
|
|
139
|
+
* request handlers on the low-level Server.
|
|
140
|
+
*
|
|
141
|
+
* Each tool handler receives the shared ctx merged with the SDK extra,
|
|
142
|
+
* so tools get real Storage, Registry, BackendRegistry instances.
|
|
143
|
+
*
|
|
144
|
+
* @param {Record<string, object>} tools - Map of tool name to definition.
|
|
145
|
+
*/
|
|
146
|
+
tools(tools) {
|
|
147
|
+
this._tools = { ...this._tools, ...tools };
|
|
148
|
+
const registered = this._tools;
|
|
149
|
+
const ctx = this.ctx;
|
|
150
|
+
const prepared = this.prepared;
|
|
151
|
+
|
|
152
|
+
this.server.setRequestHandler(
|
|
153
|
+
ListToolsRequestSchema,
|
|
154
|
+
async function list() {
|
|
155
|
+
// Agent sessions only see agent-scoped tools. Without this
|
|
156
|
+
// filter, confused agents call add_agent/start_work which
|
|
157
|
+
// destroys workspaces and starts duplicate reactor loops.
|
|
158
|
+
const entries = Object.entries(registered)
|
|
159
|
+
.filter(function scoped([name]) {
|
|
160
|
+
return !ctx.agentName || AGENT_TOOLS.has(name);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
tools: entries.map(function serialize([name, tool]) {
|
|
165
|
+
return {
|
|
166
|
+
name,
|
|
167
|
+
description: tool.description,
|
|
168
|
+
inputSchema: tool.schema ?? EMPTY_SCHEMA
|
|
169
|
+
};
|
|
170
|
+
})
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
this.server.setRequestHandler(
|
|
176
|
+
CallToolRequestSchema,
|
|
177
|
+
async function call(request, extra) {
|
|
178
|
+
const name = request.params.name;
|
|
179
|
+
const tool = registered[name];
|
|
180
|
+
|
|
181
|
+
if (!tool) {
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
184
|
+
isError: true
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Block agent sessions from calling orchestration-only tools
|
|
189
|
+
if (ctx.agentName && !AGENT_TOOLS.has(name)) {
|
|
190
|
+
log.warn(`agent ${ctx.agentName} blocked from calling host tool: ${name}`);
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: 'text', text: `Tool "${name}" is not available to agents. Use plan, pr, comment, read, refactor, environment, or context.` }],
|
|
193
|
+
isError: true
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Gate execution on hydration — tools are registered eagerly so
|
|
198
|
+
// clients can list them, but execution requires hydrated ctx
|
|
199
|
+
// (hub, config, identity). If hydration is still in progress,
|
|
200
|
+
// the call blocks here until it completes.
|
|
201
|
+
await prepared;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
// Pass ctx directly so tools can mutate shared state (e.g.
|
|
205
|
+
// start attaching ctx.hub and ctx.config). SDK extra is
|
|
206
|
+
// available as ctx._extra for tools that need protocol context.
|
|
207
|
+
ctx._extra = extra;
|
|
208
|
+
log.debug(`tool call: ${name}`, { args: request.params.arguments });
|
|
209
|
+
const result = await tool.exec(request.params.arguments ?? {}, ctx);
|
|
210
|
+
log.debug(`tool done: ${name}`);
|
|
211
|
+
return result;
|
|
212
|
+
} catch (err) {
|
|
213
|
+
log.error(`tool error: ${name} — ${err.message}`);
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: 'text', text: err.message }],
|
|
216
|
+
isError: true
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Register prompt definitions on the server.
|
|
225
|
+
*
|
|
226
|
+
* @param {Record<string, object>} prompts - Map of prompt name to definition.
|
|
227
|
+
*/
|
|
228
|
+
prompts(prompts) {
|
|
229
|
+
const registered = prompts;
|
|
230
|
+
|
|
231
|
+
this.server.setRequestHandler(
|
|
232
|
+
ListPromptsRequestSchema,
|
|
233
|
+
async function list() {
|
|
234
|
+
return {
|
|
235
|
+
prompts: Object.entries(registered).map(function serialize([name, prompt]) {
|
|
236
|
+
return { name, description: prompt.description };
|
|
237
|
+
})
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
this.server.setRequestHandler(
|
|
243
|
+
GetPromptRequestSchema,
|
|
244
|
+
async function get(request) {
|
|
245
|
+
const prompt = registered[request.params.name];
|
|
246
|
+
if (!prompt) throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
247
|
+
return prompt.exec(request.params.arguments ?? {});
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Register resource definitions on the server.
|
|
254
|
+
*
|
|
255
|
+
* @param {Record<string, object>} resources - Map of resource URI to definition.
|
|
256
|
+
*/
|
|
257
|
+
resources(resources) {
|
|
258
|
+
const registered = resources;
|
|
259
|
+
|
|
260
|
+
this.server.setRequestHandler(
|
|
261
|
+
ListResourcesRequestSchema,
|
|
262
|
+
async function list() {
|
|
263
|
+
return {
|
|
264
|
+
resources: Object.entries(registered).map(function serialize([uri, resource]) {
|
|
265
|
+
return { uri, name: resource.name };
|
|
266
|
+
})
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
this.server.setRequestHandler(
|
|
272
|
+
ReadResourceRequestSchema,
|
|
273
|
+
async function read(request) {
|
|
274
|
+
const resource = registered[request.params.uri];
|
|
275
|
+
if (!resource) throw new Error(`Unknown resource: ${request.params.uri}`);
|
|
276
|
+
return resource.exec();
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Async initialization: create real dependencies, build the shared
|
|
283
|
+
* context, then register all tools.
|
|
284
|
+
*
|
|
285
|
+
* The ctx object is a live reference — tools that mutate it (e.g.
|
|
286
|
+
* start attaches ctx.hub and ctx.config at runtime) affect all
|
|
287
|
+
* subsequent tool calls.
|
|
288
|
+
*
|
|
289
|
+
* @returns {Promise<void>}
|
|
290
|
+
*/
|
|
291
|
+
async _prepare() {
|
|
292
|
+
const home = this.config.home ?? process.env.LORELI_HOME ?? join(homedir(), '.loreli');
|
|
293
|
+
const config = new Config();
|
|
294
|
+
config.loadLocal('loreli.yml');
|
|
295
|
+
config.merge(this.config);
|
|
296
|
+
|
|
297
|
+
const identityRegistry = new Registry();
|
|
298
|
+
const backendRegistry = new BackendRegistry();
|
|
299
|
+
const storage = new Storage({ home });
|
|
300
|
+
|
|
301
|
+
const orchestrator = new Orchestrator({ hub: null, identityRegistry, backendRegistry, storage });
|
|
302
|
+
orchestrator.cfg = config;
|
|
303
|
+
|
|
304
|
+
this.ctx = {
|
|
305
|
+
hub: null,
|
|
306
|
+
storage,
|
|
307
|
+
identityRegistry,
|
|
308
|
+
backendRegistry,
|
|
309
|
+
orchestrator,
|
|
310
|
+
planner: new PlannerWorkflow(orchestrator, null),
|
|
311
|
+
action: new ActionWorkflow(orchestrator, null),
|
|
312
|
+
risk: new RiskWorkflow(orchestrator, null),
|
|
313
|
+
review: new ReviewWorkflow(orchestrator, null),
|
|
314
|
+
clientIdentity: null,
|
|
315
|
+
clientName: null,
|
|
316
|
+
clientVersion: null,
|
|
317
|
+
config,
|
|
318
|
+
repo: config.get('repo') ?? null,
|
|
319
|
+
categoryId: null,
|
|
320
|
+
sessionId: null,
|
|
321
|
+
server: this
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Register tools, prompts, and resources BEFORE hydration so MCP
|
|
325
|
+
// clients can list capabilities immediately after connecting.
|
|
326
|
+
// Tool execution is gated on this.prepared (see tools() wrapper),
|
|
327
|
+
// so calling a tool before hydration finishes will block until ready
|
|
328
|
+
// rather than returning a -32601 "Method not found" error.
|
|
329
|
+
const tools = allTools();
|
|
330
|
+
this.tools(tools);
|
|
331
|
+
|
|
332
|
+
// Register empty prompts and resources handlers so MCP clients
|
|
333
|
+
// don't get -32601 errors when listing them. The server declares
|
|
334
|
+
// these capabilities in its constructor; without handlers the SDK
|
|
335
|
+
// returns "Method not found" which clients treat as a server failure.
|
|
336
|
+
this.prompts({});
|
|
337
|
+
this.resources({});
|
|
338
|
+
|
|
339
|
+
this._hookInitialized();
|
|
340
|
+
|
|
341
|
+
// Hydrate agent context from session storage when env vars are present.
|
|
342
|
+
// These are injected by workspace.prepare() into each agent's .mcp.json
|
|
343
|
+
// so the agent's MCP server knows its identity without parameters.
|
|
344
|
+
// Runs AFTER tool registration so clients aren't blocked.
|
|
345
|
+
await this._hydrate(storage);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Hydrate ctx from persisted session storage when agent env vars are set.
|
|
350
|
+
*
|
|
351
|
+
* Reads LORELI_SESSION and LORELI_AGENT from process.env, loads the
|
|
352
|
+
* agent's stored state, and populates ctx fields. This enables agent
|
|
353
|
+
* tools to resolve repo, identity, role, and task context server-side
|
|
354
|
+
* without requiring the agent to pass identifiers as parameters.
|
|
355
|
+
*
|
|
356
|
+
* @param {Storage} storage - Session storage instance.
|
|
357
|
+
* @returns {Promise<void>}
|
|
358
|
+
*/
|
|
359
|
+
async _hydrate(storage) {
|
|
360
|
+
const sessionId = process.env.LORELI_SESSION;
|
|
361
|
+
const agentName = process.env.LORELI_AGENT;
|
|
362
|
+
if (!sessionId || !agentName) return;
|
|
363
|
+
|
|
364
|
+
this.ctx.sessionId = sessionId;
|
|
365
|
+
this.ctx.agentName = agentName;
|
|
366
|
+
|
|
367
|
+
const data = await storage.load(sessionId, agentName);
|
|
368
|
+
if (data) {
|
|
369
|
+
this.ctx.repo = data.repo ?? process.env.LORELI_REPO ?? this.ctx.repo ?? null;
|
|
370
|
+
if (data.identity) {
|
|
371
|
+
// Identity.toJSON() stores `name` as full name and `character`
|
|
372
|
+
// as the raw character. The constructor expects `name` = character.
|
|
373
|
+
const raw = data.identity;
|
|
374
|
+
this.ctx.clientIdentity = new Identity({
|
|
375
|
+
name: raw.character ?? raw.name,
|
|
376
|
+
instance: raw.instance ?? 0,
|
|
377
|
+
faction: raw.faction,
|
|
378
|
+
provider: raw.provider,
|
|
379
|
+
model: raw.model,
|
|
380
|
+
theme: raw.theme
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
log.info(`agent context hydrated: ${agentName} (session: ${sessionId}, repo: ${this.ctx.repo})`);
|
|
384
|
+
} else {
|
|
385
|
+
// Fallback to env var when storage has no data yet (first startup)
|
|
386
|
+
this.ctx.repo = process.env.LORELI_REPO ?? this.ctx.repo ?? null;
|
|
387
|
+
log.warn(`agent session data not found for ${agentName} in ${sessionId}, using env fallback`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Agent MCP servers need a working hub for plan/pr/comment tools.
|
|
391
|
+
// The host process sets up the hub via start, but agent
|
|
392
|
+
// subprocesses never call start — they hydrate from env vars
|
|
393
|
+
// instead. Create the hub here so agent tools work immediately.
|
|
394
|
+
await this._hydrateHub();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Create a GitHubHub for agent MCP servers.
|
|
399
|
+
*
|
|
400
|
+
* Uses GITHUB_TOKEN from the environment (inherited from the parent
|
|
401
|
+
* process that spawned this agent). If no token is available, the
|
|
402
|
+
* hub stays null and tools that need it will return clear errors.
|
|
403
|
+
*
|
|
404
|
+
* Also loads the loreli.yml config from the target repo when possible
|
|
405
|
+
* so agent tools have access to labels, merge settings, etc.
|
|
406
|
+
*
|
|
407
|
+
* @returns {Promise<void>}
|
|
408
|
+
*/
|
|
409
|
+
async _hydrateHub() {
|
|
410
|
+
let token = process.env.GITHUB_TOKEN;
|
|
411
|
+
|
|
412
|
+
// Keep a file-based fallback for startup paths where the host did
|
|
413
|
+
// not provide GITHUB_TOKEN to this process. create() writes
|
|
414
|
+
// .git/loreli.env inside .git/ (inherently unstageable by git).
|
|
415
|
+
if (!token) {
|
|
416
|
+
try {
|
|
417
|
+
const { readFileSync } = await import('node:fs');
|
|
418
|
+
const env = readFileSync(join(process.cwd(), '.git', 'loreli.env'), 'utf8');
|
|
419
|
+
const match = env.match(/^GITHUB_TOKEN=(.+)$/m);
|
|
420
|
+
if (match) token = match[1].trim();
|
|
421
|
+
} catch {
|
|
422
|
+
// File doesn't exist — not a workspace created by create()
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!token) {
|
|
427
|
+
log.debug('no GITHUB_TOKEN — agent hub not created');
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const { GitHubHub } = await import('loreli/hub');
|
|
433
|
+
this.ctx.hub = new GitHubHub({ token });
|
|
434
|
+
|
|
435
|
+
// Wire hub to workflows so agent tools that delegate work
|
|
436
|
+
if (this.ctx.planner) this.ctx.planner.hub = this.ctx.hub;
|
|
437
|
+
if (this.ctx.action) this.ctx.action.hub = this.ctx.hub;
|
|
438
|
+
if (this.ctx.risk) this.ctx.risk.hub = this.ctx.hub;
|
|
439
|
+
if (this.ctx.review) this.ctx.review.hub = this.ctx.hub;
|
|
440
|
+
|
|
441
|
+
log.info('agent hub created from GITHUB_TOKEN');
|
|
442
|
+
} catch (err) {
|
|
443
|
+
log.warn(`agent hub creation failed: ${err.message}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Load config from loreli.yml when repo is known
|
|
447
|
+
if (this.ctx.repo && this.ctx.hub) {
|
|
448
|
+
try {
|
|
449
|
+
const config = new Config();
|
|
450
|
+
config.merge(this.config);
|
|
451
|
+
await config.load(this.ctx.hub, this.ctx.repo);
|
|
452
|
+
config.merge(this.config);
|
|
453
|
+
this.ctx.config = config;
|
|
454
|
+
this.ctx.orchestrator.cfg = config;
|
|
455
|
+
log.info(`agent config loaded from ${this.ctx.repo}/loreli.yml`);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
log.debug(`agent config load skipped: ${err.message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Hook into the SDK's `oninitialized` callback to extract client identity
|
|
464
|
+
* from the standard MCP handshake. The SDK's `Server._oninitialize()`
|
|
465
|
+
* already handles the initialize request, stores `clientInfo` via
|
|
466
|
+
* `this._clientVersion`, and negotiates protocol version. We must NOT
|
|
467
|
+
* override that handler — doing so would break capability negotiation.
|
|
468
|
+
*
|
|
469
|
+
* Instead, we use `server.oninitialized` (fired after the SDK completes
|
|
470
|
+
* its own initialization) and read back the stored values via
|
|
471
|
+
* `getClientVersion()` and `getClientCapabilities()`.
|
|
472
|
+
*/
|
|
473
|
+
_hookInitialized() {
|
|
474
|
+
const ctx = this.ctx;
|
|
475
|
+
const server = this.server;
|
|
476
|
+
|
|
477
|
+
server.oninitialized = function onInitialized() {
|
|
478
|
+
const client = server.getClientVersion?.() ?? { name: 'unknown', version: 'unknown' };
|
|
479
|
+
const name = client.name ?? 'unknown';
|
|
480
|
+
const ver = client.version ?? 'unknown';
|
|
481
|
+
|
|
482
|
+
log.info(`MCP client connected: ${name} v${ver}`);
|
|
483
|
+
|
|
484
|
+
ctx.clientName = name;
|
|
485
|
+
ctx.clientVersion = ver;
|
|
486
|
+
|
|
487
|
+
const theme = pick(ctx.config?.get?.('theme'));
|
|
488
|
+
|
|
489
|
+
ctx.clientIdentity = new Identity({
|
|
490
|
+
name: 'Loreli',
|
|
491
|
+
provider: 'loreli',
|
|
492
|
+
model: 'mcp',
|
|
493
|
+
theme,
|
|
494
|
+
faction: council(theme),
|
|
495
|
+
council: true
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
log.info(`client identity set: ${ctx.clientIdentity.name} (${council(theme)})`);
|
|
499
|
+
|
|
500
|
+
// Write a readiness marker so CLI backends (Claude, Cursor) can
|
|
501
|
+
// detect that MCP tools are discoverable. Without this, the backend
|
|
502
|
+
// sends the prompt before Claude Code finishes its MCP handshake,
|
|
503
|
+
// and the agent starts working without access to MCP tools.
|
|
504
|
+
try {
|
|
505
|
+
const dir = join(process.cwd(), '.loreli');
|
|
506
|
+
mkdirSync(dir, { recursive: true });
|
|
507
|
+
writeFileSync(join(dir, 'mcp-ready'), String(Date.now()));
|
|
508
|
+
} catch { /* cwd may not be writable — best-effort */ }
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Start the server with a given transport.
|
|
514
|
+
* Registers SIGINT/SIGTERM handlers that persist session state,
|
|
515
|
+
* garbage-collect orphaned tmux panes, and exit cleanly.
|
|
516
|
+
* Tracked agent panes are preserved so agents survive orchestrator restart.
|
|
517
|
+
*
|
|
518
|
+
* @param {object} [transport] - MCP transport (defaults to stdio).
|
|
519
|
+
* @returns {Promise<Server>} The connected server.
|
|
520
|
+
*/
|
|
521
|
+
async start(transport) {
|
|
522
|
+
// Do NOT await this.prepared here — tool schemas are registered
|
|
523
|
+
// eagerly so clients can list them immediately. Tool execution
|
|
524
|
+
// gates on this.prepared internally (see hydration gate in tools()).
|
|
525
|
+
// Blocking here would delay the transport connection until hydration
|
|
526
|
+
// finishes, which defeats the purpose of eager registration.
|
|
527
|
+
|
|
528
|
+
if (!transport) {
|
|
529
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
530
|
+
transport = new StdioServerTransport();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Signal handlers: persist state, gc orphans, preserve tracked panes.
|
|
534
|
+
// Agent sessions (running inside tmux panes) create a local stub
|
|
535
|
+
// orchestrator with zero registered agents. Running gc() from that
|
|
536
|
+
// stub would treat every pane in the session as orphaned and kill
|
|
537
|
+
// all sibling agents. Only the host process owns the real registry.
|
|
538
|
+
const self = this;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* @param {'SIGINT'|'SIGTERM'|'transport-close'} reason - What triggered shutdown.
|
|
542
|
+
*/
|
|
543
|
+
async function gracefulExit(reason) {
|
|
544
|
+
if (self.ctx?.agentName) {
|
|
545
|
+
log.info(`agent ${self.ctx.agentName} MCP server exiting (${reason})`);
|
|
546
|
+
process.exit(0);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
log.info(`host MCP server exiting (${reason})`);
|
|
551
|
+
|
|
552
|
+
self.ctx?.orchestrator?.unwatch?.();
|
|
553
|
+
self.ctx?.orchestrator?.stopMonitor?.();
|
|
554
|
+
|
|
555
|
+
if (self.ctx?.sessionId && self.ctx?.storage) {
|
|
556
|
+
for (const [name, agent] of self.ctx.orchestrator?.agents ?? []) {
|
|
557
|
+
try {
|
|
558
|
+
await self.ctx.storage.save(self.ctx.sessionId, name, {
|
|
559
|
+
identity: agent.identity?.toJSON?.() ?? agent.identity,
|
|
560
|
+
role: agent.role,
|
|
561
|
+
state: agent.state,
|
|
562
|
+
paneId: agent.paneId ?? null,
|
|
563
|
+
lastActivity: new Date().toISOString()
|
|
564
|
+
});
|
|
565
|
+
} catch { /* best-effort persistence */ }
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Clean orphaned tmux panes that aren't tracked by any agent.
|
|
570
|
+
// Tracked agent panes are preserved for rehydration on next start.
|
|
571
|
+
try { await self.ctx?.orchestrator?.gc?.(); } catch { /* best-effort */ }
|
|
572
|
+
|
|
573
|
+
process.exit(0);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
process.on('SIGINT', function onSigint() { gracefulExit('SIGINT'); });
|
|
577
|
+
process.on('SIGTERM', function onSigterm() { gracefulExit('SIGTERM'); });
|
|
578
|
+
|
|
579
|
+
log.info('loreli MCP server starting');
|
|
580
|
+
|
|
581
|
+
transport.onerror = function onTransportError(err) {
|
|
582
|
+
log.error(`MCP transport error: ${err?.message ?? err}`);
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
await this.server.connect(transport);
|
|
586
|
+
|
|
587
|
+
this.server.onerror = function onServerError(err) {
|
|
588
|
+
log.error(`MCP server error: ${err?.message ?? err}`);
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// Transport disconnect (client closed pipe, host exited) bypasses
|
|
592
|
+
// process signals entirely. Wire the SDK's onclose so cleanup runs
|
|
593
|
+
// regardless of how the connection ends.
|
|
594
|
+
this.server.onclose = function onTransportClose() { gracefulExit('transport-close'); };
|
|
595
|
+
|
|
596
|
+
log.info('loreli MCP server connected');
|
|
597
|
+
|
|
598
|
+
return this.server;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Identity } from 'loreli/identity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve agent context from session storage.
|
|
5
|
+
*
|
|
6
|
+
* Reads the agent's persisted session data using the sessionId and
|
|
7
|
+
* agentName from ctx (hydrated at MCP server startup from env vars).
|
|
8
|
+
* Reconstructs a live Identity instance so hub stamping and label
|
|
9
|
+
* methods work correctly. Throws when context is not available — this
|
|
10
|
+
* means the tool was called from a non-agent MCP client.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} ctx - Execution context with sessionId, agentName, storage.
|
|
13
|
+
* @returns {Promise<{repo: string, identity: Identity, role: string, task: object|null, issue: number|null, paneId: string|null}>}
|
|
14
|
+
* @throws {Error} When agent context is not available.
|
|
15
|
+
*/
|
|
16
|
+
export async function context(ctx) {
|
|
17
|
+
if (!ctx.sessionId || !ctx.agentName) {
|
|
18
|
+
throw new Error('Agent context not available. This tool can only be called by spawned agents.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const data = await ctx.storage.load(ctx.sessionId, ctx.agentName);
|
|
22
|
+
if (!data) {
|
|
23
|
+
throw new Error(`Session data not found for agent "${ctx.agentName}" in session "${ctx.sessionId}".`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const raw = data.identity;
|
|
27
|
+
const identity = new Identity({
|
|
28
|
+
name: raw.character ?? raw.name,
|
|
29
|
+
instance: raw.instance ?? 0,
|
|
30
|
+
faction: raw.faction,
|
|
31
|
+
provider: raw.provider,
|
|
32
|
+
model: raw.model,
|
|
33
|
+
theme: raw.theme
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
repo: data.repo,
|
|
38
|
+
identity,
|
|
39
|
+
role: data.role,
|
|
40
|
+
task: data.task ?? null,
|
|
41
|
+
issue: data.claimedIssue ?? data.task?.issue ?? null,
|
|
42
|
+
paneId: data.paneId ?? null
|
|
43
|
+
};
|
|
44
|
+
}
|