muuuuse 0.1.0 → 0.2.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/src/runtime.js ADDED
@@ -0,0 +1,683 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { spawn } = require("node:child_process");
4
+
5
+ const {
6
+ PRESETS,
7
+ detectAgent,
8
+ readClaudeAnswers,
9
+ readCodexAnswers,
10
+ readGeminiAnswers,
11
+ selectClaudeSessionFile,
12
+ selectCodexSessionFile,
13
+ selectGeminiSessionFile,
14
+ } = require("./agents");
15
+ const { capturePaneText, getPaneChildProcesses, getPaneInfo, paneExists, sendTextAndEnter, setPaneTitle } = require("./tmux");
16
+ const {
17
+ BRAND,
18
+ CONTROLLER_WAIT_MS,
19
+ POLL_MS,
20
+ appendJsonl,
21
+ createId,
22
+ getControllerPath,
23
+ getFileSize,
24
+ getSeatPaths,
25
+ hashText,
26
+ isPidAlive,
27
+ readAppendedText,
28
+ readJson,
29
+ resetDir,
30
+ sanitizeRelayText,
31
+ sleep,
32
+ writeJson,
33
+ } = require("./util");
34
+
35
+ function killExistingSeatDaemon(sessionName, seatId) {
36
+ const { daemonPath } = getSeatPaths(sessionName, seatId);
37
+ const daemon = readJson(daemonPath, null);
38
+ if (daemon?.pid && isPidAlive(daemon.pid)) {
39
+ try {
40
+ process.kill(daemon.pid, "SIGTERM");
41
+ } catch (error) {
42
+ // Ignore stale pid races.
43
+ }
44
+ }
45
+ }
46
+
47
+ function spawnSeatDaemon(sessionName, seatId, binPath) {
48
+ const child = spawn(process.execPath, [binPath, "daemon", sessionName, String(seatId)], {
49
+ detached: true,
50
+ stdio: "ignore",
51
+ env: process.env,
52
+ });
53
+ child.unref();
54
+ }
55
+
56
+ function armSeat({ seatId, paneInfo, binPath }) {
57
+ killExistingSeatDaemon(paneInfo.sessionName, seatId);
58
+ const seatPaths = getSeatPaths(paneInfo.sessionName, seatId);
59
+ resetDir(seatPaths.dir);
60
+
61
+ const meta = {
62
+ seatId,
63
+ sessionName: paneInfo.sessionName,
64
+ paneId: paneInfo.paneId,
65
+ windowIndex: paneInfo.windowIndex,
66
+ windowName: paneInfo.windowName,
67
+ cwd: paneInfo.currentPath,
68
+ armedAt: new Date().toISOString(),
69
+ instanceId: createId(12),
70
+ };
71
+
72
+ writeJson(seatPaths.metaPath, meta);
73
+ setPaneTitle(paneInfo.paneId, `muuuuse ${seatId}`);
74
+ spawnSeatDaemon(paneInfo.sessionName, seatId, binPath);
75
+ return meta;
76
+ }
77
+
78
+ function listArmedSeats(sessionName) {
79
+ return [1, 2]
80
+ .map((seatId) => {
81
+ const seatPaths = getSeatPaths(sessionName, seatId);
82
+ const meta = readJson(seatPaths.metaPath, null);
83
+ if (!meta || !paneExists(meta.paneId)) {
84
+ return null;
85
+ }
86
+ return meta;
87
+ })
88
+ .filter((entry) => entry !== null);
89
+ }
90
+
91
+ function findSeatByPane(sessionName, paneId) {
92
+ return listArmedSeats(sessionName).find((seat) => seat.paneId === paneId) || null;
93
+ }
94
+
95
+ function configureScript({ sessionName, paneId, steps }) {
96
+ const seat = findSeatByPane(sessionName, paneId);
97
+ if (!seat) {
98
+ throw new Error("This pane is not armed. Run `muuuuse 1` or `muuuuse 2` first.");
99
+ }
100
+
101
+ const normalizedSteps = steps
102
+ .map((step) => sanitizeRelayText(step))
103
+ .filter((step) => step.length > 0);
104
+
105
+ if (normalizedSteps.length === 0) {
106
+ throw new Error("Script mode needs at least one non-empty step.");
107
+ }
108
+
109
+ const seatPaths = getSeatPaths(sessionName, seat.seatId);
110
+ writeJson(seatPaths.scriptPath, {
111
+ mode: "script",
112
+ cursor: 0,
113
+ steps: normalizedSteps,
114
+ updatedAt: new Date().toISOString(),
115
+ });
116
+ setPaneTitle(paneId, `muuuuse ${seat.seatId} script`);
117
+ return {
118
+ seatId: seat.seatId,
119
+ steps: normalizedSteps,
120
+ };
121
+ }
122
+
123
+ function enableLiveMode({ sessionName, paneId }) {
124
+ const seat = findSeatByPane(sessionName, paneId);
125
+ if (!seat) {
126
+ throw new Error("This pane is not armed. Run `muuuuse 1` or `muuuuse 2` first.");
127
+ }
128
+
129
+ const seatPaths = getSeatPaths(sessionName, seat.seatId);
130
+ fs.rmSync(seatPaths.scriptPath, { force: true });
131
+ setPaneTitle(paneId, `muuuuse ${seat.seatId}`);
132
+ return seat;
133
+ }
134
+
135
+ function queueSeatCommand(sessionName, seatId, text, meta = {}) {
136
+ const seatPaths = getSeatPaths(sessionName, seatId);
137
+ const payload = sanitizeRelayText(text);
138
+ if (!payload) {
139
+ return null;
140
+ }
141
+
142
+ const command = {
143
+ id: createId(12),
144
+ type: "deliver",
145
+ text: payload,
146
+ createdAt: new Date().toISOString(),
147
+ ...meta,
148
+ };
149
+ appendJsonl(seatPaths.commandsPath, command);
150
+ return command;
151
+ }
152
+
153
+ class Controller {
154
+ constructor(sessionName, options = {}) {
155
+ this.sessionName = sessionName;
156
+ this.seedSeat = options.seedSeat === 2 ? 2 : 1;
157
+ this.seedText = sanitizeRelayText(options.seedText || "");
158
+ this.maxRelays = Number.isFinite(options.maxRelays) ? options.maxRelays : Number.POSITIVE_INFINITY;
159
+ this.relayCount = 0;
160
+ this.stopped = false;
161
+ this.offsets = { 1: 0, 2: 0 };
162
+ this.controllerPath = getControllerPath(sessionName);
163
+ this.seats = new Map();
164
+ }
165
+
166
+ print(line = "") {
167
+ process.stdout.write(`${line}\n`);
168
+ }
169
+
170
+ installSignalHandlers() {
171
+ const stop = () => {
172
+ this.stopped = true;
173
+ };
174
+ process.once("SIGINT", stop);
175
+ process.once("SIGTERM", stop);
176
+ }
177
+
178
+ async waitForSeats() {
179
+ this.print(`${BRAND} controller is waiting for seats 1 and 2 in tmux session ${this.sessionName}.`);
180
+
181
+ while (!this.stopped) {
182
+ const seats = listArmedSeats(this.sessionName);
183
+ this.seats = new Map(seats.map((seat) => [seat.seatId, seat]));
184
+ if (this.seats.has(1) && this.seats.has(2)) {
185
+ return;
186
+ }
187
+ await sleep(CONTROLLER_WAIT_MS);
188
+ }
189
+ }
190
+
191
+ initializeOffsets() {
192
+ for (const seatId of [1, 2]) {
193
+ const { eventsPath } = getSeatPaths(this.sessionName, seatId);
194
+ this.offsets[seatId] = getFileSize(eventsPath);
195
+ }
196
+ }
197
+
198
+ writeState() {
199
+ writeJson(this.controllerPath, {
200
+ pid: process.pid,
201
+ sessionName: this.sessionName,
202
+ seedSeat: this.seedSeat,
203
+ relays: this.relayCount,
204
+ startedAt: new Date().toISOString(),
205
+ });
206
+ }
207
+
208
+ removeState() {
209
+ const current = readJson(this.controllerPath, null);
210
+ if (current?.pid === process.pid) {
211
+ fs.rmSync(this.controllerPath, { force: true });
212
+ }
213
+ }
214
+
215
+ async run() {
216
+ this.installSignalHandlers();
217
+ await this.waitForSeats();
218
+ if (this.stopped) {
219
+ return 0;
220
+ }
221
+
222
+ this.initializeOffsets();
223
+ this.writeState();
224
+
225
+ this.print(`${BRAND} linked seat 1 and seat 2 in session ${this.sessionName}.`);
226
+ this.print("Final answers only. Remote routing belongs to Codeman.");
227
+
228
+ if (this.seedText) {
229
+ queueSeatCommand(this.sessionName, this.seedSeat, this.seedText, {
230
+ source: "controller_seed",
231
+ });
232
+ this.print(`Kickoff -> seat ${this.seedSeat}: ${previewText(this.seedText)}`);
233
+ }
234
+
235
+ try {
236
+ while (!this.stopped) {
237
+ await this.forwardNewAnswers();
238
+ if (this.relayCount >= this.maxRelays) {
239
+ this.print(`${BRAND} hit the relay cap (${this.maxRelays}).`);
240
+ return 0;
241
+ }
242
+ await sleep(POLL_MS);
243
+ }
244
+ return 0;
245
+ } finally {
246
+ this.removeState();
247
+ }
248
+ }
249
+
250
+ async forwardNewAnswers() {
251
+ for (const seatId of [1, 2]) {
252
+ const targetSeatId = seatId === 1 ? 2 : 1;
253
+ const { eventsPath } = getSeatPaths(this.sessionName, seatId);
254
+ const { nextOffset, text } = readAppendedText(eventsPath, this.offsets[seatId]);
255
+ this.offsets[seatId] = nextOffset;
256
+ if (!text.trim()) {
257
+ continue;
258
+ }
259
+
260
+ const entries = text
261
+ .split("\n")
262
+ .map((line) => line.trim())
263
+ .filter((line) => line.length > 0)
264
+ .map((line) => {
265
+ try {
266
+ return JSON.parse(line);
267
+ } catch (error) {
268
+ return null;
269
+ }
270
+ })
271
+ .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
272
+
273
+ for (const entry of entries) {
274
+ const queued = queueSeatCommand(this.sessionName, targetSeatId, entry.text, {
275
+ sourceSeat: seatId,
276
+ sourceEventId: entry.id,
277
+ });
278
+ if (!queued) {
279
+ continue;
280
+ }
281
+ this.relayCount += 1;
282
+ this.print(`[${seatId} -> ${targetSeatId}] ${previewText(entry.text)}`);
283
+ if (this.relayCount >= this.maxRelays) {
284
+ return;
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ class SeatDaemon {
292
+ constructor(sessionName, seatId) {
293
+ this.sessionName = sessionName;
294
+ this.seatId = seatId;
295
+ this.paths = getSeatPaths(sessionName, seatId);
296
+ this.commandOffset = 0;
297
+ this.stopped = false;
298
+ this.liveState = {
299
+ type: null,
300
+ pid: null,
301
+ currentPath: null,
302
+ sessionFile: null,
303
+ offset: 0,
304
+ lastMessageId: null,
305
+ processStartedAtMs: null,
306
+ };
307
+ this.paneState = {
308
+ text: "",
309
+ changedAt: 0,
310
+ lastCandidateHash: null,
311
+ };
312
+ }
313
+
314
+ installSignalHandlers() {
315
+ const stop = () => {
316
+ this.stopped = true;
317
+ };
318
+ process.once("SIGINT", stop);
319
+ process.once("SIGTERM", stop);
320
+ }
321
+
322
+ writeDaemonState() {
323
+ writeJson(this.paths.daemonPath, {
324
+ pid: process.pid,
325
+ seatId: this.seatId,
326
+ sessionName: this.sessionName,
327
+ startedAt: new Date().toISOString(),
328
+ });
329
+ }
330
+
331
+ removeDaemonState() {
332
+ const current = readJson(this.paths.daemonPath, null);
333
+ if (current?.pid === process.pid) {
334
+ fs.rmSync(this.paths.daemonPath, { force: true });
335
+ }
336
+ }
337
+
338
+ async run() {
339
+ this.installSignalHandlers();
340
+ this.writeDaemonState();
341
+
342
+ try {
343
+ while (!this.stopped) {
344
+ await this.tick();
345
+ await sleep(POLL_MS);
346
+ }
347
+ return 0;
348
+ } finally {
349
+ this.removeDaemonState();
350
+ }
351
+ }
352
+
353
+ async tick() {
354
+ const meta = readJson(this.paths.metaPath, null);
355
+ if (!meta || !paneExists(meta.paneId)) {
356
+ this.writeStatus({ state: "waiting_for_pane" });
357
+ return;
358
+ }
359
+
360
+ const paneInfo = getPaneInfo(meta.paneId);
361
+ if (!paneInfo) {
362
+ this.writeStatus({ state: "waiting_for_pane" });
363
+ return;
364
+ }
365
+
366
+ if (paneInfo.currentPath !== meta.cwd || paneInfo.windowName !== meta.windowName) {
367
+ writeJson(this.paths.metaPath, {
368
+ ...meta,
369
+ cwd: paneInfo.currentPath,
370
+ windowName: paneInfo.windowName,
371
+ paneId: paneInfo.paneId,
372
+ });
373
+ }
374
+
375
+ const script = readJson(this.paths.scriptPath, null);
376
+ this.processCommands(meta, script);
377
+
378
+ if (script && Array.isArray(script.steps) && script.steps.length > 0) {
379
+ this.writeStatus({
380
+ state: "script",
381
+ scriptSteps: script.steps.length,
382
+ cursor: script.cursor || 0,
383
+ cwd: paneInfo.currentPath,
384
+ });
385
+ return;
386
+ }
387
+
388
+ this.collectLiveAnswers(meta, paneInfo);
389
+ }
390
+
391
+ processCommands(meta, script) {
392
+ const { nextOffset, text } = readAppendedText(this.paths.commandsPath, this.commandOffset);
393
+ this.commandOffset = nextOffset;
394
+ if (!text.trim()) {
395
+ return;
396
+ }
397
+
398
+ const commands = text
399
+ .split("\n")
400
+ .map((line) => line.trim())
401
+ .filter((line) => line.length > 0)
402
+ .map((line) => {
403
+ try {
404
+ return JSON.parse(line);
405
+ } catch (error) {
406
+ return null;
407
+ }
408
+ })
409
+ .filter((entry) => entry && entry.type === "deliver" && typeof entry.text === "string");
410
+
411
+ for (const command of commands) {
412
+ if (script && Array.isArray(script.steps) && script.steps.length > 0) {
413
+ this.handleScriptTurn(meta, script, command);
414
+ continue;
415
+ }
416
+
417
+ sendTextAndEnter(meta.paneId, command.text);
418
+ }
419
+ }
420
+
421
+ handleScriptTurn(meta, script, command) {
422
+ const steps = Array.isArray(script.steps) ? script.steps.filter((step) => step.length > 0) : [];
423
+ if (steps.length === 0) {
424
+ return;
425
+ }
426
+
427
+ const cursor = Number.isInteger(script.cursor) ? script.cursor : 0;
428
+ const nextText = steps[cursor % steps.length];
429
+ sendTextAndEnter(meta.paneId, nextText);
430
+
431
+ const nextScript = {
432
+ ...script,
433
+ cursor: (cursor + 1) % steps.length,
434
+ updatedAt: new Date().toISOString(),
435
+ };
436
+ writeJson(this.paths.scriptPath, nextScript);
437
+ this.emitAnswer({
438
+ id: createId(12),
439
+ origin: "script",
440
+ text: nextText,
441
+ createdAt: new Date().toISOString(),
442
+ });
443
+ }
444
+
445
+ collectLiveAnswers(meta, paneInfo) {
446
+ const detectedAgent = detectAgent(getPaneChildProcesses(meta.paneId));
447
+ if (!detectedAgent) {
448
+ this.liveState = {
449
+ type: null,
450
+ pid: null,
451
+ currentPath: paneInfo.currentPath,
452
+ sessionFile: null,
453
+ offset: 0,
454
+ lastMessageId: null,
455
+ processStartedAtMs: null,
456
+ };
457
+ this.writeStatus({
458
+ state: "armed",
459
+ cwd: paneInfo.currentPath,
460
+ agent: null,
461
+ });
462
+ return;
463
+ }
464
+
465
+ const changed =
466
+ this.liveState.type !== detectedAgent.type ||
467
+ this.liveState.pid !== detectedAgent.pid ||
468
+ this.liveState.currentPath !== paneInfo.currentPath;
469
+
470
+ if (changed) {
471
+ this.liveState = {
472
+ type: detectedAgent.type,
473
+ pid: detectedAgent.pid,
474
+ currentPath: paneInfo.currentPath,
475
+ sessionFile: null,
476
+ offset: 0,
477
+ lastMessageId: null,
478
+ processStartedAtMs: detectedAgent.processStartedAtMs,
479
+ };
480
+ }
481
+
482
+ if (!this.liveState.sessionFile) {
483
+ this.liveState.sessionFile = resolveSessionFile(
484
+ detectedAgent.type,
485
+ paneInfo.currentPath,
486
+ detectedAgent.processStartedAtMs,
487
+ meta.paneId
488
+ );
489
+ if (this.liveState.sessionFile) {
490
+ if (detectedAgent.type === "gemini") {
491
+ const baseline = readGeminiAnswers(this.liveState.sessionFile, null);
492
+ this.liveState.lastMessageId = baseline.lastMessageId;
493
+ this.liveState.offset = baseline.fileSize;
494
+ } else {
495
+ this.liveState.offset = getFileSize(this.liveState.sessionFile);
496
+ }
497
+ }
498
+ }
499
+
500
+ if (!this.liveState.sessionFile) {
501
+ this.writeStatus({
502
+ state: "armed",
503
+ cwd: paneInfo.currentPath,
504
+ agent: detectedAgent.type,
505
+ log: "waiting_for_session_log",
506
+ });
507
+ return;
508
+ }
509
+
510
+ const answers = [];
511
+ if (detectedAgent.type === "codex") {
512
+ const result = readCodexAnswers(this.liveState.sessionFile, this.liveState.offset);
513
+ this.liveState.offset = result.nextOffset;
514
+ answers.push(...result.answers);
515
+ } else if (detectedAgent.type === "claude") {
516
+ const result = readClaudeAnswers(this.liveState.sessionFile, this.liveState.offset);
517
+ this.liveState.offset = result.nextOffset;
518
+ answers.push(...result.answers);
519
+ } else if (detectedAgent.type === "gemini") {
520
+ const result = readGeminiAnswers(this.liveState.sessionFile, this.liveState.lastMessageId);
521
+ this.liveState.lastMessageId = result.lastMessageId;
522
+ this.liveState.offset = result.fileSize;
523
+ answers.push(...result.answers);
524
+ }
525
+
526
+ for (const answer of answers) {
527
+ this.emitAnswer({
528
+ id: answer.id || createId(12),
529
+ origin: detectedAgent.type,
530
+ text: answer.text,
531
+ createdAt: answer.timestamp || new Date().toISOString(),
532
+ });
533
+ }
534
+
535
+ this.collectPaneFallback(meta, detectedAgent);
536
+
537
+ this.writeStatus({
538
+ state: "armed",
539
+ cwd: paneInfo.currentPath,
540
+ agent: detectedAgent.type,
541
+ log: this.liveState.sessionFile,
542
+ lastAnswerAt: answers.length > 0 ? answers[answers.length - 1].timestamp : undefined,
543
+ });
544
+ }
545
+
546
+ collectPaneFallback(meta, detectedAgent) {
547
+ if (detectedAgent.type !== "codex") {
548
+ return;
549
+ }
550
+
551
+ const paneText = capturePaneText(meta.paneId, 240);
552
+ if (!paneText.trim()) {
553
+ return;
554
+ }
555
+
556
+ if (paneText !== this.paneState.text) {
557
+ this.paneState.text = paneText;
558
+ this.paneState.changedAt = Date.now();
559
+ return;
560
+ }
561
+
562
+ if (Date.now() - this.paneState.changedAt < 2200) {
563
+ return;
564
+ }
565
+
566
+ const candidate = extractCodexPaneAnswer(paneText);
567
+ if (!candidate) {
568
+ return;
569
+ }
570
+
571
+ const candidateHash = hashText(`codex-pane:${candidate}:${paneText}`);
572
+ if (candidateHash === this.paneState.lastCandidateHash) {
573
+ return;
574
+ }
575
+
576
+ this.paneState.lastCandidateHash = candidateHash;
577
+ this.emitAnswer({
578
+ id: createId(12),
579
+ origin: "codex_pane",
580
+ text: candidate,
581
+ createdAt: new Date().toISOString(),
582
+ });
583
+ }
584
+
585
+ emitAnswer(entry) {
586
+ const text = sanitizeRelayText(entry.text);
587
+ if (!text) {
588
+ return;
589
+ }
590
+
591
+ appendJsonl(this.paths.eventsPath, {
592
+ id: entry.id || createId(12),
593
+ type: "answer",
594
+ seatId: this.seatId,
595
+ origin: entry.origin || "unknown",
596
+ text,
597
+ createdAt: entry.createdAt || new Date().toISOString(),
598
+ });
599
+ }
600
+
601
+ writeStatus(extra) {
602
+ writeJson(this.paths.statusPath, {
603
+ seatId: this.seatId,
604
+ sessionName: this.sessionName,
605
+ pid: process.pid,
606
+ updatedAt: new Date().toISOString(),
607
+ ...extra,
608
+ });
609
+ }
610
+ }
611
+
612
+ function resolveSessionFile(agentType, currentPath, processStartedAtMs, paneId = null) {
613
+ if (agentType === "codex") {
614
+ return selectCodexSessionFile(currentPath, processStartedAtMs, paneId);
615
+ }
616
+ if (agentType === "claude") {
617
+ return selectClaudeSessionFile(currentPath, processStartedAtMs);
618
+ }
619
+ if (agentType === "gemini") {
620
+ return selectGeminiSessionFile(currentPath, processStartedAtMs);
621
+ }
622
+ return null;
623
+ }
624
+
625
+ function previewText(text, maxLength = 88) {
626
+ const compact = sanitizeRelayText(text).replace(/\s+/g, " ");
627
+ if (compact.length <= maxLength) {
628
+ return compact;
629
+ }
630
+ return `${compact.slice(0, maxLength - 3)}...`;
631
+ }
632
+
633
+ function extractCodexPaneAnswer(paneText) {
634
+ const lines = String(paneText || "").replace(/\r/g, "").split("\n");
635
+ let promptIndex = -1;
636
+
637
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
638
+ if (/^\s*›\s+/.test(lines[index])) {
639
+ promptIndex = index;
640
+ break;
641
+ }
642
+ }
643
+
644
+ const searchEnd = promptIndex === -1 ? lines.length - 1 : promptIndex - 1;
645
+ let answerStart = -1;
646
+
647
+ for (let index = searchEnd; index >= 0; index -= 1) {
648
+ if (/^\s*•\s+/.test(lines[index])) {
649
+ answerStart = index;
650
+ break;
651
+ }
652
+ }
653
+
654
+ if (answerStart === -1) {
655
+ return "";
656
+ }
657
+
658
+ const answerLines = lines.slice(answerStart, searchEnd + 1);
659
+ while (answerLines.length > 0 && answerLines[answerLines.length - 1].trim().length === 0) {
660
+ answerLines.pop();
661
+ }
662
+ if (answerLines.length === 0) {
663
+ return "";
664
+ }
665
+
666
+ answerLines[0] = answerLines[0].replace(/^\s*•\s+/, "");
667
+ return sanitizeRelayText(answerLines.join("\n"));
668
+ }
669
+
670
+ module.exports = {
671
+ BRAND,
672
+ Controller,
673
+ PRESETS,
674
+ SeatDaemon,
675
+ armSeat,
676
+ configureScript,
677
+ enableLiveMode,
678
+ extractCodexPaneAnswer,
679
+ findSeatByPane,
680
+ listArmedSeats,
681
+ previewText,
682
+ queueSeatCommand,
683
+ };