muuuuse 2.1.0 → 2.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/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.0",
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
@@ -51,6 +51,10 @@ const CHILD_ENV_DROP_KEYS = [
51
51
  "CODEX_THREAD_ID",
52
52
  ];
53
53
 
54
+ function normalizeFlowMode(flowMode) {
55
+ return String(flowMode || "").trim().toLowerCase() === "on" ? "on" : "off";
56
+ }
57
+
54
58
  function resolveShell() {
55
59
  const shell = String(process.env.SHELL || "").trim();
56
60
  return shell || "/bin/bash";
@@ -214,6 +218,51 @@ function parseAnswerEntries(text) {
214
218
  .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
215
219
  }
216
220
 
221
+ function readSessionHeaderText(filePath, maxBytes = 16384) {
222
+ try {
223
+ const fd = fs.openSync(filePath, "r");
224
+ try {
225
+ const buffer = Buffer.alloc(maxBytes);
226
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
227
+ return buffer.toString("utf8", 0, bytesRead);
228
+ } finally {
229
+ fs.closeSync(fd);
230
+ }
231
+ } catch {
232
+ return "";
233
+ }
234
+ }
235
+
236
+ function readSessionFileStartedAtMs(agentType, filePath) {
237
+ try {
238
+ if (agentType === "gemini") {
239
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8"));
240
+ return Date.parse(entry.startTime || entry.lastUpdated || "");
241
+ }
242
+
243
+ const header = readSessionHeaderText(filePath);
244
+ const lines = header
245
+ .split("\n")
246
+ .map((line) => line.trim())
247
+ .filter(Boolean);
248
+
249
+ for (const line of lines) {
250
+ const entry = JSON.parse(line);
251
+ if (agentType === "codex" && entry?.type === "session_meta") {
252
+ return Date.parse(entry.payload?.timestamp || "");
253
+ }
254
+
255
+ if (agentType === "claude") {
256
+ return Date.parse(entry.timestamp || entry.message?.timestamp || "");
257
+ }
258
+ }
259
+ } catch {
260
+ return Number.NaN;
261
+ }
262
+
263
+ return Number.NaN;
264
+ }
265
+
217
266
  function readProcessCwd(pid) {
218
267
  if (!Number.isInteger(pid) || pid <= 0) {
219
268
  return null;
@@ -387,35 +436,23 @@ function readSeatChallenge(paths, sessionName) {
387
436
  }
388
437
 
389
438
  async function sendTextAndEnter(child, text, shouldAbort = () => false) {
390
- const lines = String(text || "").replace(/\r/g, "").split("\n");
439
+ const payload = String(text || "")
440
+ .replace(/\r/g, "")
441
+ .replace(/\s*\n+\s*/g, " ")
442
+ .replace(/[ \t]{2,}/g, " ")
443
+ .trim();
391
444
 
392
- for (let index = 0; index < lines.length; index += 1) {
445
+ if (payload.length > 0) {
393
446
  if (shouldAbort() || !child) {
394
447
  return false;
395
448
  }
396
449
 
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
- }
406
-
407
- if (index < lines.length - 1) {
408
- if (shouldAbort()) {
409
- return false;
410
- }
411
-
412
- try {
413
- child.write("\r");
414
- } catch {
415
- return false;
416
- }
417
- await sleep(TYPE_DELAY_MS);
450
+ try {
451
+ child.write(payload);
452
+ } catch {
453
+ return false;
418
454
  }
455
+ await sleep(TYPE_DELAY_MS);
419
456
  }
420
457
 
421
458
  if (shouldAbort() || !child) {
@@ -435,6 +472,7 @@ class ArmedSeat {
435
472
  constructor(options) {
436
473
  this.seatId = options.seatId;
437
474
  this.partnerSeatId = options.seatId === 1 ? 2 : 1;
475
+ this.flowMode = normalizeFlowMode(options.flowMode);
438
476
  this.cwd = normalizeWorkingPath(options.cwd);
439
477
  this.sessionName = resolveSessionName(this.cwd, this.seatId);
440
478
  if (!this.sessionName) {
@@ -503,6 +541,7 @@ class ArmedSeat {
503
541
  seatId: this.seatId,
504
542
  partnerSeatId: this.partnerSeatId,
505
543
  sessionName: this.sessionName,
544
+ flowMode: this.flowMode,
506
545
  cwd: this.cwd,
507
546
  pid: process.pid,
508
547
  childPid: this.childPid,
@@ -517,6 +556,7 @@ class ArmedSeat {
517
556
  seatId: this.seatId,
518
557
  partnerSeatId: this.partnerSeatId,
519
558
  sessionName: this.sessionName,
559
+ flowMode: this.flowMode,
520
560
  cwd: this.cwd,
521
561
  pid: process.pid,
522
562
  childPid: this.childPid,
@@ -1079,6 +1119,10 @@ class ArmedSeat {
1079
1119
  this.liveState.sessionFile = resolvedSessionFile;
1080
1120
  this.liveState.offset = 0;
1081
1121
  this.liveState.lastMessageId = null;
1122
+ const sessionStartedAtMs = readSessionFileStartedAtMs(detectedAgent.type, resolvedSessionFile);
1123
+ if (Number.isFinite(sessionStartedAtMs)) {
1124
+ this.liveState.captureSinceMs = Math.min(this.liveState.captureSinceMs, sessionStartedAtMs);
1125
+ }
1082
1126
  }
1083
1127
 
1084
1128
  if (!this.liveState.sessionFile) {
@@ -1096,7 +1140,8 @@ class ArmedSeat {
1096
1140
  const result = readCodexAnswers(
1097
1141
  this.liveState.sessionFile,
1098
1142
  this.liveState.offset,
1099
- this.liveState.captureSinceMs
1143
+ this.liveState.captureSinceMs,
1144
+ { flowMode: this.flowMode === "on" }
1100
1145
  );
1101
1146
  this.liveState.offset = result.nextOffset;
1102
1147
  answers.push(...result.answers);
@@ -1104,7 +1149,8 @@ class ArmedSeat {
1104
1149
  const result = readClaudeAnswers(
1105
1150
  this.liveState.sessionFile,
1106
1151
  this.liveState.offset,
1107
- this.liveState.captureSinceMs
1152
+ this.liveState.captureSinceMs,
1153
+ { flowMode: this.flowMode === "on" }
1108
1154
  );
1109
1155
  this.liveState.offset = result.nextOffset;
1110
1156
  answers.push(...result.answers);
@@ -1112,7 +1158,8 @@ class ArmedSeat {
1112
1158
  const result = readGeminiAnswers(
1113
1159
  this.liveState.sessionFile,
1114
1160
  this.liveState.lastMessageId,
1115
- this.liveState.captureSinceMs
1161
+ this.liveState.captureSinceMs,
1162
+ { flowMode: this.flowMode === "on" }
1116
1163
  );
1117
1164
  this.liveState.lastMessageId = result.lastMessageId;
1118
1165
  this.liveState.offset = result.fileSize;
@@ -1161,7 +1208,7 @@ class ArmedSeat {
1161
1208
  }
1162
1209
 
1163
1210
  const pendingInboundContext = this.getPendingInboundContext();
1164
- if (pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1211
+ if (this.flowMode !== "on" && pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1165
1212
  this.log(`[${this.seatId}] suppressed relay loop: ${previewText(payload)}`);
1166
1213
  return;
1167
1214
  }
@@ -1223,6 +1270,7 @@ class ArmedSeat {
1223
1270
  this.writeStatus({
1224
1271
  state: live.state,
1225
1272
  agent: live.agent,
1273
+ flowMode: this.flowMode,
1226
1274
  cwd: live.cwd,
1227
1275
  log: live.log,
1228
1276
  lastAnswerAt: live.lastAnswerAt,
@@ -1239,7 +1287,8 @@ class ArmedSeat {
1239
1287
  this.installResizeHandler();
1240
1288
 
1241
1289
  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.");
1290
+ this.log("Use this shell normally. Codex, Claude, and Gemini relay automatically from their local session logs.");
1291
+ this.log(`Seat ${this.seatId} relay mode is flow ${this.flowMode}.`);
1243
1292
  if (this.seatId === 1) {
1244
1293
  this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
1245
1294
  } else {
@@ -1339,6 +1388,7 @@ function buildSeatReport(sessionName, seatId) {
1339
1388
  return {
1340
1389
  seatId,
1341
1390
  state: wrapperLive ? status?.state || "running" : "orphaned_child",
1391
+ flowMode: status?.flowMode || meta?.flowMode || "off",
1342
1392
  wrapperPid,
1343
1393
  childPid,
1344
1394
  wrapperLive,
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.",