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.
Files changed (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. 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
+ }