muuuuse 2.1.0 → 2.2.1

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/README.md CHANGED
@@ -6,15 +6,18 @@ It does one job:
6
6
  - arm terminal one with `muuuuse 1`
7
7
  - arm terminal two with `muuuuse 2`
8
8
  - have seat 1 generate a session key and seat 2 sign it
9
- - watch Codex, Claude, or Gemini for real final answers
10
- - inject that final answer into the other armed terminal
9
+ - choose per-seat relay mode with `flow on` or `flow off`
10
+ - watch Codex, Claude, or Gemini for local assistant output
11
+ - inject that output into the other armed terminal
11
12
  - keep looping until you stop it
12
13
 
13
14
  The whole surface is:
14
15
 
15
16
  ```bash
16
17
  muuuuse 1
18
+ muuuuse 1 flow on
17
19
  muuuuse 2
20
+ muuuuse 2 flow off
18
21
  muuuuse status
19
22
  muuuuse stop
20
23
  ```
@@ -24,17 +27,19 @@ muuuuse stop
24
27
  Terminal 1:
25
28
 
26
29
  ```bash
27
- muuuuse 1
30
+ muuuuse 1 flow on
28
31
  ```
29
32
 
30
33
  Terminal 2:
31
34
 
32
35
  ```bash
33
- muuuuse 2
36
+ muuuuse 2 flow off
34
37
  ```
35
38
 
36
39
  Now both shells are armed. `muuuuse 1` generates the session key, `muuuuse 2` signs it, and only that signed pair relays. Use those shells normally.
37
40
 
41
+ `flow on` means that seat sends commentary and final answers. `flow off` means that seat waits for final answers only. Each seat decides what it sends out, so mixed calibration is allowed.
42
+
38
43
  If you want Codex in one and Gemini in the other, start them inside the armed shells:
39
44
 
40
45
  ```bash
@@ -45,7 +50,7 @@ codex
45
50
  gemini
46
51
  ```
47
52
 
48
- `🔌Muuuuse` tails the local session logs for supported CLIs, detects the final answer, types that answer into the other seat, and then sends Enter as a separate keystroke.
53
+ `🔌Muuuuse` tails the local session logs for supported CLIs, relays according to each seat's flow mode, types that output into the other seat, and then sends Enter as a separate keystroke.
49
54
 
50
55
  Check the live state from any terminal:
51
56
 
@@ -64,7 +69,7 @@ muuuuse stop
64
69
  - no tmux
65
70
  - state lives under `~/.muuuuse`
66
71
  - only the signed armed pair can exchange relay events
67
- - supported final-answer detection is built for Codex, Claude, and Gemini
72
+ - supported relay detection is built for Codex, Claude, and Gemini
68
73
  - `codeman` remains the larger transport/control layer; `muuuuse` stays local and minimal
69
74
 
70
75
  ## Install
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muuuuse",
3
- "version": "2.1.0",
4
- "description": "🔌Muuuuse arms two regular terminals and relays final answers between them.",
3
+ "version": "2.2.1",
4
+ "description": "🔌Muuuuse arms two regular terminals and relays assistant output between them.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
7
  "muuuuse": "bin/muuse.js"
package/src/agents.js CHANGED
@@ -459,14 +459,17 @@ function extractCodexAssistantText(content) {
459
459
  .join("\n");
460
460
  }
461
461
 
462
- function parseCodexFinalLine(line) {
462
+ function parseCodexAssistantLine(line, options = {}) {
463
+ const flowMode = options.flowMode === true;
463
464
  try {
464
465
  const entry = JSON.parse(line);
465
466
  if (entry?.type !== "response_item" || entry.payload?.type !== "message" || entry.payload?.role !== "assistant") {
466
467
  return null;
467
468
  }
468
469
 
469
- if (entry.payload?.phase !== "final_answer") {
470
+ const phase = String(entry.payload?.phase || "").trim().toLowerCase();
471
+ const relayablePhase = phase === "final_answer" || (flowMode && phase === "commentary");
472
+ if (!relayablePhase) {
470
473
  return null;
471
474
  }
472
475
 
@@ -485,6 +488,10 @@ function parseCodexFinalLine(line) {
485
488
  }
486
489
  }
487
490
 
491
+ function parseCodexFinalLine(line) {
492
+ return parseCodexAssistantLine(line, { flowMode: false });
493
+ }
494
+
488
495
  function isAnswerNewEnough(answer, sinceMs = null) {
489
496
  if (!Number.isFinite(sinceMs)) {
490
497
  return true;
@@ -498,13 +505,13 @@ function isAnswerNewEnough(answer, sinceMs = null) {
498
505
  return answerMs >= sinceMs;
499
506
  }
500
507
 
501
- function readCodexAnswers(filePath, offset, sinceMs = null) {
508
+ function readCodexAnswers(filePath, offset, sinceMs = null, options = {}) {
502
509
  const { nextOffset, text } = readAppendedText(filePath, offset);
503
510
  const answers = text
504
511
  .split("\n")
505
512
  .map((line) => line.trim())
506
513
  .filter((line) => line.length > 0)
507
- .map((line) => parseCodexFinalLine(line))
514
+ .map((line) => parseCodexAssistantLine(line, options))
508
515
  .filter((entry) => entry !== null)
509
516
  .filter((entry) => isAnswerNewEnough(entry, sinceMs));
510
517
 
@@ -547,7 +554,8 @@ function selectClaudeSessionFile(currentPath, processStartedAtMs, options = {})
547
554
  return selectSessionCandidatePath(candidates, currentPath, processStartedAtMs);
548
555
  }
549
556
 
550
- function extractClaudeAssistantText(content) {
557
+ function extractClaudeAssistantText(content, options = {}) {
558
+ const flowMode = options.flowMode === true;
551
559
  if (!Array.isArray(content)) {
552
560
  return "";
553
561
  }
@@ -560,20 +568,28 @@ function extractClaudeAssistantText(content) {
560
568
  if (item.type === "text" && typeof item.text === "string") {
561
569
  return [item.text.trim()];
562
570
  }
571
+ if (flowMode && item.type === "thinking" && typeof item.thinking === "string") {
572
+ return [item.thinking.trim()];
573
+ }
563
574
  return [];
564
575
  })
565
576
  .filter((text) => text.length > 0)
566
577
  .join("\n");
567
578
  }
568
579
 
569
- function parseClaudeFinalLine(line) {
580
+ function parseClaudeAssistantLine(line, options = {}) {
581
+ const flowMode = options.flowMode === true;
570
582
  try {
571
583
  const entry = JSON.parse(line);
572
- if (entry?.type !== "assistant" || entry.message?.role !== "assistant" || entry.message?.stop_reason !== "end_turn") {
584
+ if (entry?.type !== "assistant" || entry.message?.role !== "assistant") {
573
585
  return null;
574
586
  }
575
587
 
576
- const text = sanitizeRelayText(extractClaudeAssistantText(entry.message.content));
588
+ if (!flowMode && entry.message?.stop_reason !== "end_turn") {
589
+ return null;
590
+ }
591
+
592
+ const text = sanitizeRelayText(extractClaudeAssistantText(entry.message.content, options));
577
593
  if (!text) {
578
594
  return null;
579
595
  }
@@ -588,13 +604,17 @@ function parseClaudeFinalLine(line) {
588
604
  }
589
605
  }
590
606
 
591
- function readClaudeAnswers(filePath, offset, sinceMs = null) {
607
+ function parseClaudeFinalLine(line) {
608
+ return parseClaudeAssistantLine(line, { flowMode: false });
609
+ }
610
+
611
+ function readClaudeAnswers(filePath, offset, sinceMs = null, options = {}) {
592
612
  const { nextOffset, text } = readAppendedText(filePath, offset);
593
613
  const answers = text
594
614
  .split("\n")
595
615
  .map((line) => line.trim())
596
616
  .filter((line) => line.length > 0)
597
- .map((line) => parseClaudeFinalLine(line))
617
+ .map((line) => parseClaudeAssistantLine(line, options))
598
618
  .filter((entry) => entry !== null)
599
619
  .filter((entry) => isAnswerNewEnough(entry, sinceMs));
600
620
 
package/src/cli.js CHANGED
@@ -55,12 +55,10 @@ async function main(argv = process.argv.slice(2)) {
55
55
  }
56
56
 
57
57
  if (command === "1" || command === "2") {
58
- if (argv.length > 1) {
59
- throw new Error(`\`muuuuse ${command}\` takes no extra arguments. Run it directly in the terminal you want to arm.`);
60
- }
61
-
58
+ const flowMode = parseSeatFlowMode(command, argv.slice(1));
62
59
  const seat = new ArmedSeat({
63
60
  cwd: process.cwd(),
61
+ flowMode,
64
62
  seatId: Number(command),
65
63
  });
66
64
  const code = await seat.run();
@@ -74,6 +72,7 @@ function renderSeatStatus(seat) {
74
72
  const bits = [
75
73
  `seat ${seat.seatId}: ${seat.state}`,
76
74
  `agent ${seat.agent || "idle"}`,
75
+ `flow ${seat.flowMode || "off"}`,
77
76
  `relays ${seat.relayCount}`,
78
77
  `wrapper ${seat.wrapperPid || "-"}`,
79
78
  `child ${seat.childPid || "-"}`,
@@ -99,6 +98,23 @@ function renderSeatStatus(seat) {
99
98
  return output;
100
99
  }
101
100
 
101
+ function parseSeatFlowMode(command, args) {
102
+ if (args.length === 0) {
103
+ return "off";
104
+ }
105
+
106
+ if (args.length === 2 && String(args[0]).trim().toLowerCase() === "flow") {
107
+ const flowMode = String(args[1]).trim().toLowerCase();
108
+ if (flowMode === "on" || flowMode === "off") {
109
+ return flowMode;
110
+ }
111
+ }
112
+
113
+ throw new Error(
114
+ `\`muuuuse ${command}\` accepts either no extra arguments or \`flow on\` / \`flow off\`. Run it directly in the terminal you want to arm.`
115
+ );
116
+ }
117
+
102
118
  module.exports = {
103
119
  main,
104
120
  };
package/src/runtime.js CHANGED
@@ -35,7 +35,8 @@ const {
35
35
  writeJson,
36
36
  } = require("./util");
37
37
 
38
- const TYPE_DELAY_MS = 70;
38
+ const TYPE_CHUNK_DELAY_MS = 18;
39
+ const TYPE_CHUNK_SIZE = 24;
39
40
  const MIRROR_SUPPRESSION_WINDOW_MS = 30 * 1000;
40
41
  const PENDING_RELAY_CONTEXT_TTL_MS = 2 * 60 * 1000;
41
42
  const EMITTED_ANSWER_TTL_MS = 5 * 60 * 1000;
@@ -51,6 +52,10 @@ const CHILD_ENV_DROP_KEYS = [
51
52
  "CODEX_THREAD_ID",
52
53
  ];
53
54
 
55
+ function normalizeFlowMode(flowMode) {
56
+ return String(flowMode || "").trim().toLowerCase() === "on" ? "on" : "off";
57
+ }
58
+
54
59
  function resolveShell() {
55
60
  const shell = String(process.env.SHELL || "").trim();
56
61
  return shell || "/bin/bash";
@@ -214,6 +219,51 @@ function parseAnswerEntries(text) {
214
219
  .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
215
220
  }
216
221
 
222
+ function readSessionHeaderText(filePath, maxBytes = 16384) {
223
+ try {
224
+ const fd = fs.openSync(filePath, "r");
225
+ try {
226
+ const buffer = Buffer.alloc(maxBytes);
227
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
228
+ return buffer.toString("utf8", 0, bytesRead);
229
+ } finally {
230
+ fs.closeSync(fd);
231
+ }
232
+ } catch {
233
+ return "";
234
+ }
235
+ }
236
+
237
+ function readSessionFileStartedAtMs(agentType, filePath) {
238
+ try {
239
+ if (agentType === "gemini") {
240
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8"));
241
+ return Date.parse(entry.startTime || entry.lastUpdated || "");
242
+ }
243
+
244
+ const header = readSessionHeaderText(filePath);
245
+ const lines = header
246
+ .split("\n")
247
+ .map((line) => line.trim())
248
+ .filter(Boolean);
249
+
250
+ for (const line of lines) {
251
+ const entry = JSON.parse(line);
252
+ if (agentType === "codex" && entry?.type === "session_meta") {
253
+ return Date.parse(entry.payload?.timestamp || "");
254
+ }
255
+
256
+ if (agentType === "claude") {
257
+ return Date.parse(entry.timestamp || entry.message?.timestamp || "");
258
+ }
259
+ }
260
+ } catch {
261
+ return Number.NaN;
262
+ }
263
+
264
+ return Number.NaN;
265
+ }
266
+
217
267
  function readProcessCwd(pid) {
218
268
  if (!Number.isInteger(pid) || pid <= 0) {
219
269
  return null;
@@ -386,35 +436,56 @@ function readSeatChallenge(paths, sessionName) {
386
436
  };
387
437
  }
388
438
 
389
- async function sendTextAndEnter(child, text, shouldAbort = () => false) {
390
- const lines = String(text || "").replace(/\r/g, "").split("\n");
439
+ function normalizeRelayPayloadForTyping(text) {
440
+ return String(text || "")
441
+ .replace(/\r/g, "")
442
+ .replace(/\s*\n+\s*/g, " ")
443
+ .replace(/[ \t]{2,}/g, " ")
444
+ .trim();
445
+ }
391
446
 
392
- for (let index = 0; index < lines.length; index += 1) {
393
- if (shouldAbort() || !child) {
394
- return false;
395
- }
447
+ function chunkRelayPayloadForTyping(text, chunkSize = TYPE_CHUNK_SIZE) {
448
+ const normalized = String(text || "");
449
+ if (!normalized) {
450
+ return [];
451
+ }
396
452
 
397
- const line = lines[index];
398
- if (line.length > 0) {
399
- try {
400
- child.write(line);
401
- } catch {
402
- return false;
403
- }
404
- await sleep(TYPE_DELAY_MS);
405
- }
453
+ const size = Number.isInteger(chunkSize) && chunkSize > 0 ? chunkSize : TYPE_CHUNK_SIZE;
454
+ const chunks = [];
455
+ for (let index = 0; index < normalized.length; index += size) {
456
+ chunks.push(normalized.slice(index, index + size));
457
+ }
458
+ return chunks;
459
+ }
460
+
461
+ function stripPassiveTerminalInput(input) {
462
+ return String(input || "")
463
+ .replace(/\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g, "")
464
+ .replace(/\u001b\[(?:I|O)/g, "")
465
+ .replace(/\u001b\[\d+;\d+R/g, "")
466
+ .replace(/\u001b\[\?[0-9;]*c/g, "")
467
+ .replace(/\u0000/g, "");
468
+ }
469
+
470
+ function isMeaningfulTerminalInput(input) {
471
+ return stripPassiveTerminalInput(input).length > 0;
472
+ }
473
+
474
+ async function sendTextAndEnter(child, text, shouldAbort = () => false) {
475
+ const payload = normalizeRelayPayloadForTyping(text);
406
476
 
407
- if (index < lines.length - 1) {
408
- if (shouldAbort()) {
477
+ if (payload.length > 0) {
478
+ for (const chunk of chunkRelayPayloadForTyping(payload)) {
479
+ if (shouldAbort() || !child) {
409
480
  return false;
410
481
  }
411
482
 
412
483
  try {
413
- child.write("\r");
484
+ child.write(chunk);
414
485
  } catch {
415
486
  return false;
416
487
  }
417
- await sleep(TYPE_DELAY_MS);
488
+ await sleep(TYPE_CHUNK_DELAY_MS);
418
489
  }
419
490
  }
420
491
 
@@ -435,6 +506,7 @@ class ArmedSeat {
435
506
  constructor(options) {
436
507
  this.seatId = options.seatId;
437
508
  this.partnerSeatId = options.seatId === 1 ? 2 : 1;
509
+ this.flowMode = normalizeFlowMode(options.flowMode);
438
510
  this.cwd = normalizeWorkingPath(options.cwd);
439
511
  this.sessionName = resolveSessionName(this.cwd, this.seatId);
440
512
  if (!this.sessionName) {
@@ -503,6 +575,7 @@ class ArmedSeat {
503
575
  seatId: this.seatId,
504
576
  partnerSeatId: this.partnerSeatId,
505
577
  sessionName: this.sessionName,
578
+ flowMode: this.flowMode,
506
579
  cwd: this.cwd,
507
580
  pid: process.pid,
508
581
  childPid: this.childPid,
@@ -517,6 +590,7 @@ class ArmedSeat {
517
590
  seatId: this.seatId,
518
591
  partnerSeatId: this.partnerSeatId,
519
592
  sessionName: this.sessionName,
593
+ flowMode: this.flowMode,
520
594
  cwd: this.cwd,
521
595
  pid: process.pid,
522
596
  childPid: this.childPid,
@@ -732,12 +806,15 @@ class ArmedSeat {
732
806
 
733
807
  installStdinProxy() {
734
808
  const handleData = (chunk) => {
735
- this.lastUserInputAtMs = Date.now();
736
- this.pendingInboundContext = null;
809
+ const chunkText = chunk.toString("utf8");
810
+ if (isMeaningfulTerminalInput(chunkText)) {
811
+ this.lastUserInputAtMs = Date.now();
812
+ this.pendingInboundContext = null;
813
+ }
737
814
  if (!this.child) {
738
815
  return;
739
816
  }
740
- this.child.write(chunk.toString("utf8"));
817
+ this.child.write(chunkText);
741
818
  };
742
819
 
743
820
  const handleEnd = () => {
@@ -1079,6 +1156,10 @@ class ArmedSeat {
1079
1156
  this.liveState.sessionFile = resolvedSessionFile;
1080
1157
  this.liveState.offset = 0;
1081
1158
  this.liveState.lastMessageId = null;
1159
+ const sessionStartedAtMs = readSessionFileStartedAtMs(detectedAgent.type, resolvedSessionFile);
1160
+ if (Number.isFinite(sessionStartedAtMs)) {
1161
+ this.liveState.captureSinceMs = Math.min(this.liveState.captureSinceMs, sessionStartedAtMs);
1162
+ }
1082
1163
  }
1083
1164
 
1084
1165
  if (!this.liveState.sessionFile) {
@@ -1096,7 +1177,8 @@ class ArmedSeat {
1096
1177
  const result = readCodexAnswers(
1097
1178
  this.liveState.sessionFile,
1098
1179
  this.liveState.offset,
1099
- this.liveState.captureSinceMs
1180
+ this.liveState.captureSinceMs,
1181
+ { flowMode: this.flowMode === "on" }
1100
1182
  );
1101
1183
  this.liveState.offset = result.nextOffset;
1102
1184
  answers.push(...result.answers);
@@ -1104,7 +1186,8 @@ class ArmedSeat {
1104
1186
  const result = readClaudeAnswers(
1105
1187
  this.liveState.sessionFile,
1106
1188
  this.liveState.offset,
1107
- this.liveState.captureSinceMs
1189
+ this.liveState.captureSinceMs,
1190
+ { flowMode: this.flowMode === "on" }
1108
1191
  );
1109
1192
  this.liveState.offset = result.nextOffset;
1110
1193
  answers.push(...result.answers);
@@ -1112,7 +1195,8 @@ class ArmedSeat {
1112
1195
  const result = readGeminiAnswers(
1113
1196
  this.liveState.sessionFile,
1114
1197
  this.liveState.lastMessageId,
1115
- this.liveState.captureSinceMs
1198
+ this.liveState.captureSinceMs,
1199
+ { flowMode: this.flowMode === "on" }
1116
1200
  );
1117
1201
  this.liveState.lastMessageId = result.lastMessageId;
1118
1202
  this.liveState.offset = result.fileSize;
@@ -1161,7 +1245,7 @@ class ArmedSeat {
1161
1245
  }
1162
1246
 
1163
1247
  const pendingInboundContext = this.getPendingInboundContext();
1164
- if (pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1248
+ if (this.flowMode !== "on" && pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1165
1249
  this.log(`[${this.seatId}] suppressed relay loop: ${previewText(payload)}`);
1166
1250
  return;
1167
1251
  }
@@ -1223,6 +1307,7 @@ class ArmedSeat {
1223
1307
  this.writeStatus({
1224
1308
  state: live.state,
1225
1309
  agent: live.agent,
1310
+ flowMode: this.flowMode,
1226
1311
  cwd: live.cwd,
1227
1312
  log: live.log,
1228
1313
  lastAnswerAt: live.lastAnswerAt,
@@ -1239,7 +1324,8 @@ class ArmedSeat {
1239
1324
  this.installResizeHandler();
1240
1325
 
1241
1326
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
1242
- this.log("Use this shell normally. Codex, Claude, and Gemini final answers relay automatically from their local session logs.");
1327
+ this.log("Use this shell normally. Codex, Claude, and Gemini relay automatically from their local session logs.");
1328
+ this.log(`Seat ${this.seatId} relay mode is flow ${this.flowMode}.`);
1243
1329
  if (this.seatId === 1) {
1244
1330
  this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
1245
1331
  } else {
@@ -1339,6 +1425,7 @@ function buildSeatReport(sessionName, seatId) {
1339
1425
  return {
1340
1426
  seatId,
1341
1427
  state: wrapperLive ? status?.state || "running" : "orphaned_child",
1428
+ flowMode: status?.flowMode || meta?.flowMode || "off",
1342
1429
  wrapperPid,
1343
1430
  childPid,
1344
1431
  wrapperLive,
@@ -1425,7 +1512,10 @@ function stopAllSessions() {
1425
1512
  module.exports = {
1426
1513
  ArmedSeat,
1427
1514
  buildChildEnv,
1515
+ chunkRelayPayloadForTyping,
1428
1516
  getStatusReport,
1517
+ isMeaningfulTerminalInput,
1518
+ normalizeRelayPayloadForTyping,
1429
1519
  resolveSessionName,
1430
1520
  stopAllSessions,
1431
1521
  };
package/src/util.js CHANGED
@@ -245,11 +245,15 @@ function listSessionNames() {
245
245
 
246
246
  function usage() {
247
247
  return [
248
- `${BRAND} arms two regular terminals and relays final answers between them.`,
248
+ `${BRAND} arms two regular terminals and relays assistant output between them.`,
249
249
  "",
250
250
  "Usage:",
251
251
  " muuuuse 1",
252
+ " muuuuse 1 flow on",
253
+ " muuuuse 1 flow off",
252
254
  " muuuuse 2",
255
+ " muuuuse 2 flow on",
256
+ " muuuuse 2 flow off",
253
257
  " muuuuse stop",
254
258
  " muuuuse status",
255
259
  "",
@@ -257,9 +261,10 @@ function usage() {
257
261
  " 1. Run `muuuuse 1` in terminal one.",
258
262
  " 2. Run `muuuuse 2` in terminal two.",
259
263
  " 3. Seat 1 generates the session key and seat 2 signs it automatically.",
260
- " 4. Use those armed shells normally.",
261
- " 5. Codex, Claude, and Gemini final answers relay automatically from their local session logs.",
262
- " 6. Run `muuuuse status` or `muuuuse stop` from any shell.",
264
+ " 4. Optional: arm each seat with `flow on` or `flow off`.",
265
+ " 5. Use those armed shells normally.",
266
+ " 6. `flow off` sends final answers only. `flow on` keeps assistant commentary bouncing.",
267
+ " 7. Run `muuuuse status` or `muuuuse stop` from any shell.",
263
268
  "",
264
269
  "Notes:",
265
270
  " - No tmux.",