@locusai/locus-gateway 0.22.13

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/dist/index.js ADDED
@@ -0,0 +1,550 @@
1
+ // src/commands.ts
2
+ var COMMAND_REGISTRY = {
3
+ run: { cliArgs: ["run"], streaming: true },
4
+ status: { cliArgs: ["status"], streaming: false },
5
+ issues: { cliArgs: ["issue", "list"], streaming: false },
6
+ issue: { cliArgs: ["issue", "show"], streaming: false },
7
+ sprint: { cliArgs: ["sprint"], streaming: false },
8
+ plan: { cliArgs: ["plan"], streaming: true },
9
+ review: { cliArgs: ["review"], streaming: true },
10
+ iterate: { cliArgs: ["iterate"], streaming: true },
11
+ discuss: {
12
+ cliArgs: ["discuss"],
13
+ streaming: true,
14
+ requiresArgs: `Please provide a discussion topic.
15
+
16
+ Example: /discuss Should we use Redis or in-memory caching?`
17
+ },
18
+ exec: {
19
+ cliArgs: ["exec"],
20
+ streaming: true,
21
+ requiresArgs: `Please provide a prompt.
22
+
23
+ Example: /exec Add error handling to the API`
24
+ },
25
+ logs: { cliArgs: ["logs"], streaming: false },
26
+ config: { cliArgs: ["config"], streaming: false },
27
+ artifacts: { cliArgs: ["artifacts"], streaming: false }
28
+ };
29
+ var STREAMING_COMMANDS = new Set(Object.entries(COMMAND_REGISTRY).filter(([, def]) => def.streaming).map(([name]) => name));
30
+ function getCommandDefinition(command) {
31
+ return COMMAND_REGISTRY[command] ?? null;
32
+ }
33
+ // src/executor.ts
34
+ import { exec as execCb } from "node:child_process";
35
+ import { promisify } from "node:util";
36
+
37
+ // ../sdk/dist/index.js
38
+ import { spawn, spawnSync } from "node:child_process";
39
+ function invokeLocusStream(args, cwd) {
40
+ return spawn("locus", args, {
41
+ cwd: cwd ?? process.cwd(),
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ env: process.env,
44
+ shell: false
45
+ });
46
+ }
47
+ var colorEnabled = () => process.stderr.isTTY === true && process.env.NO_COLOR === undefined;
48
+ var wrap = (open, close) => (text) => colorEnabled() ? `${open}${text}${close}` : text;
49
+ var bold = wrap("\x1B[1m", "\x1B[22m");
50
+ var dim = wrap("\x1B[2m", "\x1B[22m");
51
+ var red = wrap("\x1B[31m", "\x1B[39m");
52
+ var yellow = wrap("\x1B[33m", "\x1B[39m");
53
+ var cyan = wrap("\x1B[36m", "\x1B[39m");
54
+ var gray = wrap("\x1B[90m", "\x1B[39m");
55
+ function formatData(data) {
56
+ if (!data || Object.keys(data).length === 0)
57
+ return "";
58
+ return ` ${dim(JSON.stringify(data))}`;
59
+ }
60
+ function createLogger(name) {
61
+ const prefix = dim(`[${name}]`);
62
+ return {
63
+ info(msg, data) {
64
+ process.stderr.write(`${bold(cyan("●"))} ${prefix} ${msg}${formatData(data)}
65
+ `);
66
+ },
67
+ warn(msg, data) {
68
+ process.stderr.write(`${bold(yellow("⚠"))} ${prefix} ${yellow(msg)}${formatData(data)}
69
+ `);
70
+ },
71
+ error(msg, data) {
72
+ process.stderr.write(`${bold(red("✗"))} ${prefix} ${red(msg)}${formatData(data)}
73
+ `);
74
+ },
75
+ debug(msg, data) {
76
+ if (!process.env.LOCUS_DEBUG)
77
+ return;
78
+ process.stderr.write(`${gray("⋯")} ${prefix} ${gray(msg)}${formatData(data)}
79
+ `);
80
+ }
81
+ };
82
+ }
83
+
84
+ // src/executor.ts
85
+ var exec = promisify(execCb);
86
+ var STREAM_UPDATE_INTERVAL = 2000;
87
+
88
+ class CommandExecutor {
89
+ tracker;
90
+ constructor(tracker) {
91
+ this.tracker = tracker;
92
+ }
93
+ getTracker() {
94
+ return this.tracker;
95
+ }
96
+ async executeLocusCommand(sessionId, command, args, callbacks) {
97
+ const definition = getCommandDefinition(command);
98
+ if (!definition) {
99
+ return {
100
+ text: `Unknown command: /${command}`,
101
+ format: "plain",
102
+ exitCode: 1
103
+ };
104
+ }
105
+ if (definition.requiresArgs && args.length === 0) {
106
+ return {
107
+ text: definition.requiresArgs,
108
+ format: "plain",
109
+ exitCode: 1
110
+ };
111
+ }
112
+ const conflict = this.tracker.checkExclusiveConflict(sessionId, command);
113
+ if (conflict) {
114
+ return {
115
+ text: formatConflictText(command, conflict),
116
+ format: "plain",
117
+ exitCode: 1
118
+ };
119
+ }
120
+ const fullArgs = [...definition.cliArgs, ...args];
121
+ if (definition.streaming && callbacks) {
122
+ return this.executeStreaming(sessionId, command, args, fullArgs, callbacks);
123
+ }
124
+ return this.executeBuffered(sessionId, command, args, fullArgs);
125
+ }
126
+ async executeGit(sessionId, command, args, gitArgs) {
127
+ const conflict = this.tracker.checkExclusiveConflict(sessionId, command);
128
+ if (conflict) {
129
+ return {
130
+ text: formatConflictText(command, conflict),
131
+ format: "plain",
132
+ exitCode: 1
133
+ };
134
+ }
135
+ const trackingId = this.tracker.track(sessionId, command, args);
136
+ try {
137
+ const { stdout } = await exec(`git ${gitArgs}`, { cwd: process.cwd() });
138
+ return {
139
+ text: stdout,
140
+ format: "plain",
141
+ exitCode: 0
142
+ };
143
+ } catch (error) {
144
+ const errStr = String(error);
145
+ return {
146
+ text: errStr,
147
+ format: "plain",
148
+ exitCode: 1
149
+ };
150
+ } finally {
151
+ this.tracker.untrack(sessionId, trackingId);
152
+ }
153
+ }
154
+ async executeStreaming(sessionId, command, args, fullArgs, callbacks) {
155
+ const child = invokeLocusStream(fullArgs);
156
+ const trackingId = this.tracker.track(sessionId, command, args, child);
157
+ let output = "";
158
+ let lastUpdateTime = 0;
159
+ let updateTimer = null;
160
+ let messageId = "";
161
+ const displayCmd = `locus ${fullArgs.join(" ")}`;
162
+ const startResult = await callbacks.onStart(formatStreamingText(displayCmd, "", false));
163
+ if (startResult)
164
+ messageId = startResult;
165
+ const pushUpdate = async () => {
166
+ const now = Date.now();
167
+ if (now - lastUpdateTime < STREAM_UPDATE_INTERVAL)
168
+ return;
169
+ lastUpdateTime = now;
170
+ try {
171
+ await callbacks.onUpdate(messageId, formatStreamingText(displayCmd, output, false));
172
+ } catch {}
173
+ };
174
+ child.stdout?.on("data", (chunk) => {
175
+ output += chunk.toString();
176
+ if (updateTimer)
177
+ clearTimeout(updateTimer);
178
+ updateTimer = setTimeout(pushUpdate, STREAM_UPDATE_INTERVAL);
179
+ });
180
+ child.stderr?.on("data", (chunk) => {
181
+ output += chunk.toString();
182
+ });
183
+ return new Promise((resolve) => {
184
+ child.on("close", async (exitCode) => {
185
+ this.tracker.untrack(sessionId, trackingId);
186
+ if (updateTimer)
187
+ clearTimeout(updateTimer);
188
+ const code = exitCode ?? 0;
189
+ await callbacks.onComplete(messageId, formatStreamingText(displayCmd, output, true), code);
190
+ resolve({
191
+ text: output,
192
+ format: "plain",
193
+ streaming: true,
194
+ exitCode: code
195
+ });
196
+ });
197
+ });
198
+ }
199
+ async executeBuffered(sessionId, command, args, fullArgs) {
200
+ const child = invokeLocusStream(fullArgs);
201
+ const trackingId = this.tracker.track(sessionId, command, args, child);
202
+ let output = "";
203
+ child.stdout?.on("data", (chunk) => {
204
+ output += chunk.toString();
205
+ });
206
+ child.stderr?.on("data", (chunk) => {
207
+ output += chunk.toString();
208
+ });
209
+ return new Promise((resolve) => {
210
+ child.on("close", (exitCode) => {
211
+ this.tracker.untrack(sessionId, trackingId);
212
+ const code = exitCode ?? 0;
213
+ resolve({
214
+ text: output,
215
+ format: "plain",
216
+ exitCode: code
217
+ });
218
+ });
219
+ });
220
+ }
221
+ }
222
+ function formatConflictText(blockedCommand, conflict) {
223
+ const running = conflict.runningCommand;
224
+ const runningLabel = `/${running.command}${running.args.length ? ` ${running.args.join(" ")}` : ""}`;
225
+ return `/${blockedCommand} cannot start — ${runningLabel} is already running.
226
+
227
+ Send /cancel to abort it, or wait for it to finish.`;
228
+ }
229
+ function formatStreamingText(command, output, isComplete) {
230
+ const status = isComplete ? "[DONE]" : "[RUNNING]";
231
+ const header = `${status} ${command}`;
232
+ if (!output.trim()) {
233
+ return isComplete ? `${header}
234
+
235
+ Completed.` : `${header}
236
+
237
+ Running...
238
+
239
+ Send /cancel to abort`;
240
+ }
241
+ const lines = output.trim().split(`
242
+ `);
243
+ const lastLines = lines.slice(-30).join(`
244
+ `);
245
+ const hint = isComplete ? "" : `
246
+
247
+ Send /cancel to abort`;
248
+ return `${header}
249
+
250
+ ${lastLines}${hint}`;
251
+ }
252
+ // src/formatter.ts
253
+ function bestFormat(capabilities) {
254
+ if (capabilities.supportsHTML)
255
+ return "html";
256
+ if (capabilities.supportsMarkdown)
257
+ return "markdown";
258
+ return "plain";
259
+ }
260
+ function splitMessage(text, maxLength) {
261
+ if (text.length <= maxLength)
262
+ return [text];
263
+ const chunks = [];
264
+ let remaining = text;
265
+ while (remaining.length > 0) {
266
+ if (remaining.length <= maxLength) {
267
+ chunks.push(remaining);
268
+ break;
269
+ }
270
+ let splitIdx = remaining.lastIndexOf(`
271
+ `, maxLength);
272
+ if (splitIdx === -1 || splitIdx < maxLength / 2) {
273
+ splitIdx = maxLength;
274
+ }
275
+ chunks.push(remaining.slice(0, splitIdx));
276
+ remaining = remaining.slice(splitIdx);
277
+ }
278
+ return chunks;
279
+ }
280
+ function truncate(text, maxLength) {
281
+ if (text.length <= maxLength)
282
+ return text;
283
+ return `${text.slice(0, maxLength - 20)}
284
+
285
+ ... (truncated)`;
286
+ }
287
+ // src/router.ts
288
+ class CommandRouter {
289
+ prefix;
290
+ constructor(prefix = "/") {
291
+ this.prefix = prefix;
292
+ }
293
+ parse(text) {
294
+ const trimmed = text.trim();
295
+ if (!trimmed.startsWith(this.prefix)) {
296
+ return { type: "freetext", text: trimmed };
297
+ }
298
+ const withoutPrefix = trimmed.slice(this.prefix.length);
299
+ const parts = withoutPrefix.split(/\s+/);
300
+ const rawCommand = parts[0] ?? "";
301
+ const command = rawCommand.replace(/@\S+$/, "").toLowerCase();
302
+ if (!command) {
303
+ return { type: "freetext", text: trimmed };
304
+ }
305
+ const args = parts.slice(1);
306
+ return {
307
+ type: "command",
308
+ command,
309
+ args,
310
+ raw: trimmed
311
+ };
312
+ }
313
+ }
314
+
315
+ // src/tracker.ts
316
+ var WORKSPACE_EXCLUSIVE = new Set(["run", "plan", "iterate", "exec"]);
317
+ var GIT_EXCLUSIVE = new Set(["stage", "commit", "checkout", "stash", "pr"]);
318
+ function getExclusivityGroup(command) {
319
+ if (WORKSPACE_EXCLUSIVE.has(command))
320
+ return "workspace";
321
+ if (GIT_EXCLUSIVE.has(command))
322
+ return "git";
323
+ return null;
324
+ }
325
+ var nextId = 1;
326
+
327
+ class CommandTracker {
328
+ active = new Map;
329
+ track(sessionId, command, args, childProcess = null) {
330
+ const id = String(nextId++);
331
+ const entry = {
332
+ id,
333
+ command,
334
+ args,
335
+ childProcess,
336
+ startedAt: new Date
337
+ };
338
+ const list = this.active.get(sessionId);
339
+ if (list) {
340
+ list.push(entry);
341
+ } else {
342
+ this.active.set(sessionId, [entry]);
343
+ }
344
+ return id;
345
+ }
346
+ untrack(sessionId, id) {
347
+ const list = this.active.get(sessionId);
348
+ if (!list)
349
+ return;
350
+ const idx = list.findIndex((c) => c.id === id);
351
+ if (idx !== -1)
352
+ list.splice(idx, 1);
353
+ if (list.length === 0)
354
+ this.active.delete(sessionId);
355
+ }
356
+ getActive(sessionId) {
357
+ return this.active.get(sessionId) ?? [];
358
+ }
359
+ checkExclusiveConflict(sessionId, command) {
360
+ const group = getExclusivityGroup(command);
361
+ if (!group)
362
+ return null;
363
+ const list = this.active.get(sessionId);
364
+ if (!list)
365
+ return null;
366
+ for (const entry of list) {
367
+ if (getExclusivityGroup(entry.command) === group) {
368
+ return { blocked: true, runningCommand: entry, group };
369
+ }
370
+ }
371
+ return null;
372
+ }
373
+ kill(sessionId, id) {
374
+ const list = this.active.get(sessionId);
375
+ if (!list)
376
+ return false;
377
+ const entry = list.find((c) => c.id === id);
378
+ if (!entry)
379
+ return false;
380
+ if (entry.childProcess && !entry.childProcess.killed) {
381
+ entry.childProcess.kill("SIGTERM");
382
+ }
383
+ this.untrack(sessionId, id);
384
+ return true;
385
+ }
386
+ killAll(sessionId) {
387
+ const list = this.active.get(sessionId);
388
+ if (!list)
389
+ return 0;
390
+ let killed = 0;
391
+ for (const entry of list) {
392
+ if (entry.childProcess && !entry.childProcess.killed) {
393
+ entry.childProcess.kill("SIGTERM");
394
+ }
395
+ killed++;
396
+ }
397
+ this.active.delete(sessionId);
398
+ return killed;
399
+ }
400
+ }
401
+
402
+ // src/gateway.ts
403
+ var logger = createLogger("gateway");
404
+
405
+ class Gateway {
406
+ adapters = new Map;
407
+ router;
408
+ executor;
409
+ tracker;
410
+ onEvent;
411
+ constructor(options = {}) {
412
+ this.router = new CommandRouter;
413
+ this.tracker = new CommandTracker;
414
+ this.executor = new CommandExecutor(this.tracker);
415
+ this.onEvent = options.onEvent;
416
+ }
417
+ register(adapter) {
418
+ if (this.adapters.has(adapter.platform)) {
419
+ throw new Error(`Adapter already registered for platform: ${adapter.platform}`);
420
+ }
421
+ this.adapters.set(adapter.platform, adapter);
422
+ logger.info(`Registered adapter: ${adapter.platform}`);
423
+ }
424
+ getAdapter(platform) {
425
+ return this.adapters.get(platform);
426
+ }
427
+ getRouter() {
428
+ return this.router;
429
+ }
430
+ getExecutor() {
431
+ return this.executor;
432
+ }
433
+ getTracker() {
434
+ return this.tracker;
435
+ }
436
+ async handleMessage(message) {
437
+ this.emit({ type: "message_received", message });
438
+ const adapter = this.adapters.get(message.platform);
439
+ if (!adapter) {
440
+ logger.warn(`No adapter registered for platform: ${message.platform}`);
441
+ return;
442
+ }
443
+ const parsed = this.router.parse(message.text);
444
+ if (parsed.type === "freetext") {
445
+ await this.executeCommand(adapter, message.sessionId, "exec", [parsed.text], message);
446
+ return;
447
+ }
448
+ await this.executeCommand(adapter, message.sessionId, parsed.command, parsed.args, message);
449
+ }
450
+ async start() {
451
+ const platforms = Array.from(this.adapters.keys());
452
+ logger.info(`Starting gateway with adapters: ${platforms.join(", ")}`);
453
+ for (const [platform, adapter] of this.adapters) {
454
+ try {
455
+ await adapter.start();
456
+ logger.info(`Started adapter: ${platform}`);
457
+ } catch (error) {
458
+ logger.error(`Failed to start adapter: ${platform}`, {
459
+ error: String(error)
460
+ });
461
+ throw error;
462
+ }
463
+ }
464
+ }
465
+ async stop() {
466
+ for (const [platform, adapter] of this.adapters) {
467
+ try {
468
+ await adapter.stop();
469
+ logger.info(`Stopped adapter: ${platform}`);
470
+ } catch (error) {
471
+ logger.warn(`Error stopping adapter: ${platform}`, {
472
+ error: String(error)
473
+ });
474
+ }
475
+ }
476
+ }
477
+ async executeCommand(adapter, sessionId, command, args, _originalMessage) {
478
+ this.emit({
479
+ type: "command_started",
480
+ sessionId,
481
+ command,
482
+ args
483
+ });
484
+ let streamCallbacks;
485
+ if (adapter.capabilities.supportsStreaming && adapter.edit) {
486
+ const adapterRef = adapter;
487
+ const sentMessageId = "";
488
+ streamCallbacks = {
489
+ async onStart(text) {
490
+ await adapterRef.send(sessionId, {
491
+ text,
492
+ format: "plain"
493
+ });
494
+ return sentMessageId;
495
+ },
496
+ async onUpdate(messageId, text) {
497
+ if (adapterRef.edit) {
498
+ await adapterRef.edit(sessionId, messageId, {
499
+ text,
500
+ format: "plain"
501
+ });
502
+ }
503
+ },
504
+ async onComplete(messageId, text, _exitCode) {
505
+ if (adapterRef.edit) {
506
+ await adapterRef.edit(sessionId, messageId, {
507
+ text,
508
+ format: "plain"
509
+ });
510
+ }
511
+ }
512
+ };
513
+ }
514
+ const result = await this.executor.executeLocusCommand(sessionId, command, args, streamCallbacks);
515
+ this.emit({
516
+ type: "command_completed",
517
+ sessionId,
518
+ command,
519
+ exitCode: result.exitCode
520
+ });
521
+ if (!result.streaming) {
522
+ await adapter.send(sessionId, {
523
+ text: result.text,
524
+ format: result.format,
525
+ actions: result.actions
526
+ });
527
+ }
528
+ }
529
+ emit(event) {
530
+ if (this.onEvent) {
531
+ try {
532
+ this.onEvent(event);
533
+ } catch (error) {
534
+ logger.warn("Event handler error", { error: String(error) });
535
+ }
536
+ }
537
+ }
538
+ }
539
+ export {
540
+ truncate,
541
+ splitMessage,
542
+ getCommandDefinition,
543
+ bestFormat,
544
+ STREAMING_COMMANDS,
545
+ Gateway,
546
+ CommandTracker,
547
+ CommandRouter,
548
+ CommandExecutor,
549
+ COMMAND_REGISTRY
550
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * CommandRouter — parses inbound text into structured commands or free-text.
3
+ *
4
+ * Platform adapters can customize the prefix (default "/") and
5
+ * how arguments are extracted from raw message text.
6
+ */
7
+ import type { FreeText, ParsedCommand } from "./types.js";
8
+ export declare class CommandRouter {
9
+ private prefix;
10
+ constructor(prefix?: string);
11
+ /** Parse a message text into a command or free-text. */
12
+ parse(text: string): ParsedCommand | FreeText;
13
+ }
14
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE1D,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAS;gBAEX,MAAM,SAAM;IAIxB,wDAAwD;IACxD,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,GAAG,QAAQ;CA4B9C"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * CommandTracker — tracks active commands per session for visibility,
3
+ * cleanup, and conflict detection.
4
+ *
5
+ * Generalized from the Telegram-specific tracker to work with
6
+ * string-based session IDs across any platform.
7
+ */
8
+ import type { ChildProcess } from "node:child_process";
9
+ import type { ActiveCommand, ConflictResult } from "./types.js";
10
+ export declare class CommandTracker {
11
+ private active;
12
+ /** Register a new command for a session. Returns the assigned tracking ID. */
13
+ track(sessionId: string, command: string, args: string[], childProcess?: ChildProcess | null): string;
14
+ /** Remove a tracked command by session and ID. */
15
+ untrack(sessionId: string, id: string): void;
16
+ /** Get all active commands for a session. */
17
+ getActive(sessionId: string): readonly ActiveCommand[];
18
+ /** Check if a command would conflict with an already-running exclusive command. */
19
+ checkExclusiveConflict(sessionId: string, command: string): ConflictResult | null;
20
+ /** Kill a specific command's child process and untrack it. */
21
+ kill(sessionId: string, id: string): boolean;
22
+ /** Kill all active commands for a session. */
23
+ killAll(sessionId: string): number;
24
+ }
25
+ //# sourceMappingURL=tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracker.d.ts","sourceRoot":"","sources":["../src/tracker.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EAEf,MAAM,YAAY,CAAC;AAqBpB,qBAAa,cAAc;IACzB,OAAO,CAAC,MAAM,CAAsC;IAEpD,8EAA8E;IAC9E,KAAK,CACH,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,YAAY,GAAE,YAAY,GAAG,IAAW,GACvC,MAAM;IAoBT,kDAAkD;IAClD,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI;IAU5C,6CAA6C;IAC7C,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,aAAa,EAAE;IAItD,mFAAmF;IACnF,sBAAsB,CACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GACd,cAAc,GAAG,IAAI;IAgBxB,8DAA8D;IAC9D,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO;IAe5C,8CAA8C;IAC9C,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;CAenC"}