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