muuuuse 1.4.0 → 1.4.2

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
@@ -1,36 +1,32 @@
1
1
  # 🔌Muuuuse
2
2
 
3
- `🔌Muuuuse` is a dead-simple terminal relay.
3
+ `🔌Muuuuse` is a tiny no-tmux terminal relay.
4
4
 
5
- It does one thing:
6
- - arm two raw terminals
7
- - watch for the final BEL-marked message from whatever program is running inside them
8
- - inject that final message into the other armed terminal
9
- - keep bouncing forever until you stop it
5
+ It does one job:
6
+ - arm terminal one with `muuuuse 1`
7
+ - arm terminal two with `muuuuse 2`
8
+ - watch Codex, Claude, or Gemini for real final answers
9
+ - inject that final answer into the other armed terminal
10
+ - keep looping until you stop it
10
11
 
11
- There are only three commands:
12
+ The whole surface is:
12
13
 
13
14
  ```bash
14
15
  muuuuse 1
15
16
  muuuuse 2
17
+ muuuuse status
16
18
  muuuuse stop
17
19
  ```
18
20
 
19
- No tmux.
20
- No program arguments.
21
- No status command.
22
- No doctor command.
23
- No preset logic.
24
-
25
21
  ## Flow
26
22
 
27
- Shell 1:
23
+ Terminal 1:
28
24
 
29
25
  ```bash
30
26
  muuuuse 1
31
27
  ```
32
28
 
33
- Shell 2:
29
+ Terminal 2:
34
30
 
35
31
  ```bash
36
32
  muuuuse 2
@@ -48,38 +44,29 @@ codex
48
44
  gemini
49
45
  ```
50
46
 
51
- Or run any other program. `🔌Muuuuse` is program-agnostic.
52
-
53
- When the running program rings the terminal bell, `🔌Muuuuse` takes the final output block for that turn and injects it into the partner seat.
47
+ `🔌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.
54
48
 
55
- Stop the whole loop from any other shell:
49
+ Check the live state from any terminal:
56
50
 
57
51
  ```bash
58
- muuuuse stop
52
+ muuuuse status
59
53
  ```
60
54
 
61
- ## Install
55
+ Stop the loop from any terminal, including one of the armed shells once it is back at a prompt:
62
56
 
63
57
  ```bash
64
- npm install -g muuuuse
58
+ muuuuse stop
65
59
  ```
66
60
 
67
- ## What Counts As A Relay
68
-
69
- `🔌Muuuuse` watches the armed terminal output for BEL (`\u0007`).
70
-
71
- That BEL marks the end of a turn.
72
-
73
- When it sees BEL, it:
74
- 1. grabs the final output block since the last submitted input
75
- 2. cleans the block
76
- 3. appends it to the seat event log
77
- 4. injects it into the other armed terminal followed by Enter
78
-
79
61
  ## Notes
80
62
 
81
- - local only
82
- - seat pairing defaults by current working directory
63
+ - no tmux
83
64
  - state lives under `~/.muuuuse`
84
- - `codeman` remains the richer transport layer
85
- - `🔌Muuuuse` is the tiny relay protocol
65
+ - supported final-answer detection is built for Codex, Claude, and Gemini
66
+ - `codeman` remains the larger transport/control layer; `muuuuse` stays local and minimal
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ npm install -g muuuuse
72
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muuuuse",
3
- "version": "1.4.0",
4
- "description": "🔌Muuuuse arms two raw terminals and bounces BEL-marked final output between them.",
3
+ "version": "1.4.2",
4
+ "description": "🔌Muuuuse arms two regular terminals and relays final answers between them.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
7
  "muuuuse": "bin/muuse.js"
package/src/agents.js ADDED
@@ -0,0 +1,391 @@
1
+ const { createHash } = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+
6
+ const {
7
+ getFileSize,
8
+ hashText,
9
+ readAppendedText,
10
+ sanitizeRelayText,
11
+ SESSION_MATCH_WINDOW_MS,
12
+ } = require("./util");
13
+
14
+ const CODEX_ROOT = path.join(os.homedir(), ".codex", "sessions");
15
+ const CLAUDE_ROOT = path.join(os.homedir(), ".claude", "projects");
16
+ const GEMINI_ROOT = path.join(os.homedir(), ".gemini", "tmp");
17
+ const SESSION_START_EARLY_TOLERANCE_MS = 2 * 1000;
18
+
19
+ function walkFiles(rootPath, predicate, results = []) {
20
+ try {
21
+ const entries = fs.readdirSync(rootPath, { withFileTypes: true });
22
+ for (const entry of entries) {
23
+ const absolutePath = path.join(rootPath, entry.name);
24
+ if (entry.isDirectory()) {
25
+ walkFiles(absolutePath, predicate, results);
26
+ } else if (predicate(absolutePath)) {
27
+ results.push(absolutePath);
28
+ }
29
+ }
30
+ } catch {
31
+ return results;
32
+ }
33
+
34
+ return results;
35
+ }
36
+
37
+ function commandMatches(args, command) {
38
+ const pattern = new RegExp(`(^|[\\\\/\\s])${command}(\\s|$)`, "i");
39
+ return pattern.test(args);
40
+ }
41
+
42
+ function buildDetectedAgent(type, process) {
43
+ return {
44
+ type,
45
+ pid: process.pid,
46
+ args: process.args,
47
+ cwd: process.cwd || null,
48
+ elapsedSeconds: process.elapsedSeconds,
49
+ processStartedAtMs: Date.now() - process.elapsedSeconds * 1000,
50
+ };
51
+ }
52
+
53
+ function detectAgent(processes) {
54
+ const ordered = [...processes].sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
55
+ for (const process of ordered) {
56
+ if (commandMatches(process.args, "codex")) {
57
+ return buildDetectedAgent("codex", process);
58
+ }
59
+ if (commandMatches(process.args, "claude")) {
60
+ return buildDetectedAgent("claude", process);
61
+ }
62
+ if (commandMatches(process.args, "gemini")) {
63
+ return buildDetectedAgent("gemini", process);
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function readFirstLines(filePath, maxLines = 20) {
70
+ const lines = [];
71
+ const fd = fs.openSync(filePath, "r");
72
+
73
+ try {
74
+ const buffer = Buffer.alloc(16384);
75
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
76
+ if (bytesRead === 0) {
77
+ return lines;
78
+ }
79
+
80
+ for (const line of buffer.toString("utf8", 0, bytesRead).split("\n")) {
81
+ if (line.trim().length === 0) {
82
+ continue;
83
+ }
84
+ lines.push(line.trim());
85
+ if (lines.length >= maxLines) {
86
+ break;
87
+ }
88
+ }
89
+
90
+ return lines;
91
+ } finally {
92
+ fs.closeSync(fd);
93
+ }
94
+ }
95
+
96
+ function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs) {
97
+ const cwdMatches = candidates.filter((candidate) => candidate.cwd === currentPath);
98
+ if (cwdMatches.length === 0) {
99
+ return null;
100
+ }
101
+
102
+ if (cwdMatches.length === 1) {
103
+ return cwdMatches[0].path;
104
+ }
105
+
106
+ if (!Number.isFinite(processStartedAtMs)) {
107
+ return null;
108
+ }
109
+
110
+ const preciseMatches = cwdMatches
111
+ .map((candidate) => ({
112
+ ...candidate,
113
+ diffMs: Math.abs(candidate.startedAtMs - processStartedAtMs),
114
+ relativeStartMs: candidate.startedAtMs - processStartedAtMs,
115
+ }))
116
+ .filter((candidate) => (
117
+ Number.isFinite(candidate.diffMs) &&
118
+ Number.isFinite(candidate.relativeStartMs) &&
119
+ candidate.relativeStartMs >= -SESSION_START_EARLY_TOLERANCE_MS &&
120
+ candidate.relativeStartMs <= SESSION_MATCH_WINDOW_MS
121
+ ))
122
+ .sort((left, right) => left.diffMs - right.diffMs || right.mtimeMs - left.mtimeMs);
123
+
124
+ if (preciseMatches.length === 1) {
125
+ return preciseMatches[0].path;
126
+ }
127
+
128
+ return null;
129
+ }
130
+
131
+ function readCodexCandidate(filePath) {
132
+ try {
133
+ const [firstLine] = readFirstLines(filePath, 1);
134
+ if (!firstLine) {
135
+ return null;
136
+ }
137
+
138
+ const entry = JSON.parse(firstLine);
139
+ if (entry?.type !== "session_meta" || typeof entry.payload?.cwd !== "string") {
140
+ return null;
141
+ }
142
+
143
+ return {
144
+ path: filePath,
145
+ cwd: entry.payload.cwd,
146
+ startedAtMs: Date.parse(entry.payload.timestamp),
147
+ mtimeMs: fs.statSync(filePath).mtimeMs,
148
+ };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function selectCodexSessionFile(currentPath, processStartedAtMs) {
155
+ const candidates = walkFiles(CODEX_ROOT, (filePath) => filePath.endsWith(".jsonl"))
156
+ .map((filePath) => readCodexCandidate(filePath))
157
+ .filter((candidate) => candidate !== null);
158
+
159
+ return selectSessionCandidatePath(candidates, currentPath, processStartedAtMs);
160
+ }
161
+
162
+ function extractCodexAssistantText(content) {
163
+ if (!Array.isArray(content)) {
164
+ return "";
165
+ }
166
+
167
+ return content
168
+ .flatMap((item) => {
169
+ if (!item || typeof item !== "object") {
170
+ return [];
171
+ }
172
+ if (item.type === "output_text" && typeof item.text === "string") {
173
+ return [item.text.trim()];
174
+ }
175
+ return [];
176
+ })
177
+ .filter((text) => text.length > 0)
178
+ .join("\n");
179
+ }
180
+
181
+ function parseCodexFinalLine(line) {
182
+ try {
183
+ const entry = JSON.parse(line);
184
+ if (entry?.type !== "response_item" || entry.payload?.type !== "message" || entry.payload?.role !== "assistant") {
185
+ return null;
186
+ }
187
+
188
+ if (entry.payload?.phase !== "final_answer") {
189
+ return null;
190
+ }
191
+
192
+ const text = sanitizeRelayText(extractCodexAssistantText(entry.payload.content));
193
+ if (!text) {
194
+ return null;
195
+ }
196
+
197
+ return {
198
+ id: entry.payload.id || hashText(line),
199
+ text,
200
+ timestamp: entry.timestamp || entry.payload.timestamp || new Date().toISOString(),
201
+ };
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ function isAnswerNewEnough(answer, sinceMs = null) {
208
+ if (!Number.isFinite(sinceMs)) {
209
+ return true;
210
+ }
211
+
212
+ const answerMs = Date.parse(answer?.timestamp || "");
213
+ if (!Number.isFinite(answerMs)) {
214
+ return true;
215
+ }
216
+
217
+ return answerMs >= sinceMs;
218
+ }
219
+
220
+ function readCodexAnswers(filePath, offset, sinceMs = null) {
221
+ const { nextOffset, text } = readAppendedText(filePath, offset);
222
+ const answers = text
223
+ .split("\n")
224
+ .map((line) => line.trim())
225
+ .filter((line) => line.length > 0)
226
+ .map((line) => parseCodexFinalLine(line))
227
+ .filter((entry) => entry !== null)
228
+ .filter((entry) => isAnswerNewEnough(entry, sinceMs));
229
+
230
+ return { nextOffset, answers };
231
+ }
232
+
233
+ function readClaudeCandidate(filePath) {
234
+ try {
235
+ const lines = readFirstLines(filePath, 12);
236
+ for (const line of lines) {
237
+ const entry = JSON.parse(line);
238
+ if (typeof entry.cwd !== "string") {
239
+ continue;
240
+ }
241
+
242
+ return {
243
+ path: filePath,
244
+ cwd: entry.cwd,
245
+ startedAtMs: Date.parse(entry.timestamp || entry.message?.timestamp || 0),
246
+ mtimeMs: fs.statSync(filePath).mtimeMs,
247
+ };
248
+ }
249
+ return null;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function selectClaudeSessionFile(currentPath, processStartedAtMs) {
256
+ const candidates = walkFiles(CLAUDE_ROOT, (filePath) => filePath.endsWith(".jsonl"))
257
+ .map((filePath) => readClaudeCandidate(filePath))
258
+ .filter((candidate) => candidate !== null);
259
+
260
+ return selectSessionCandidatePath(candidates, currentPath, processStartedAtMs);
261
+ }
262
+
263
+ function extractClaudeAssistantText(content) {
264
+ if (!Array.isArray(content)) {
265
+ return "";
266
+ }
267
+
268
+ return content
269
+ .flatMap((item) => {
270
+ if (!item || typeof item !== "object") {
271
+ return [];
272
+ }
273
+ if (item.type === "text" && typeof item.text === "string") {
274
+ return [item.text.trim()];
275
+ }
276
+ return [];
277
+ })
278
+ .filter((text) => text.length > 0)
279
+ .join("\n");
280
+ }
281
+
282
+ function parseClaudeFinalLine(line) {
283
+ try {
284
+ const entry = JSON.parse(line);
285
+ if (entry?.type !== "assistant" || entry.message?.role !== "assistant" || entry.message?.stop_reason !== "end_turn") {
286
+ return null;
287
+ }
288
+
289
+ const text = sanitizeRelayText(extractClaudeAssistantText(entry.message.content));
290
+ if (!text) {
291
+ return null;
292
+ }
293
+
294
+ return {
295
+ id: entry.uuid || entry.message.id || hashText(line),
296
+ text,
297
+ timestamp: entry.timestamp || new Date().toISOString(),
298
+ };
299
+ } catch {
300
+ return null;
301
+ }
302
+ }
303
+
304
+ function readClaudeAnswers(filePath, offset, sinceMs = null) {
305
+ const { nextOffset, text } = readAppendedText(filePath, offset);
306
+ const answers = text
307
+ .split("\n")
308
+ .map((line) => line.trim())
309
+ .filter((line) => line.length > 0)
310
+ .map((line) => parseClaudeFinalLine(line))
311
+ .filter((entry) => entry !== null)
312
+ .filter((entry) => isAnswerNewEnough(entry, sinceMs));
313
+
314
+ return { nextOffset, answers };
315
+ }
316
+
317
+ function readGeminiCandidate(filePath) {
318
+ try {
319
+ const raw = fs.readFileSync(filePath, "utf8");
320
+ const entry = JSON.parse(raw);
321
+ return {
322
+ path: filePath,
323
+ projectHash: entry.projectHash,
324
+ cwd: entry.projectHash,
325
+ startedAtMs: Date.parse(entry.startTime),
326
+ mtimeMs: fs.statSync(filePath).mtimeMs,
327
+ lastUpdatedMs: Date.parse(entry.lastUpdated),
328
+ };
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+
334
+ function selectGeminiSessionFile(currentPath, processStartedAtMs) {
335
+ const projectHash = createHash("sha256").update(currentPath).digest("hex");
336
+ const candidates = walkFiles(GEMINI_ROOT, (filePath) => filePath.endsWith(".json"))
337
+ .map((filePath) => readGeminiCandidate(filePath))
338
+ .filter((candidate) => candidate !== null && candidate.projectHash === projectHash);
339
+
340
+ return selectSessionCandidatePath(candidates, projectHash, processStartedAtMs);
341
+ }
342
+
343
+ function readGeminiAnswers(filePath, lastMessageId = null, sinceMs = null) {
344
+ try {
345
+ const entry = JSON.parse(fs.readFileSync(filePath, "utf8"));
346
+ const messages = Array.isArray(entry.messages) ? entry.messages : [];
347
+ const finalMessages = messages.filter((message) => {
348
+ const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : [];
349
+ return message.type === "gemini" && typeof message.content === "string" && message.content.trim() && toolCalls.length === 0;
350
+ });
351
+
352
+ let startIndex = 0;
353
+ if (lastMessageId) {
354
+ const previousIndex = finalMessages.findIndex((message) => message.id === lastMessageId);
355
+ startIndex = previousIndex === -1 ? finalMessages.length : previousIndex + 1;
356
+ }
357
+
358
+ const answers = finalMessages.slice(startIndex).map((message) => ({
359
+ id: message.id || hashText(JSON.stringify(message)),
360
+ text: sanitizeRelayText(message.content),
361
+ timestamp: message.timestamp || entry.lastUpdated || new Date().toISOString(),
362
+ }));
363
+
364
+ return {
365
+ answers: answers
366
+ .filter((answer) => answer.text.length > 0)
367
+ .filter((answer) => isAnswerNewEnough(answer, sinceMs)),
368
+ lastMessageId: finalMessages.length > 0 ? finalMessages[finalMessages.length - 1].id : lastMessageId,
369
+ fileSize: getFileSize(filePath),
370
+ };
371
+ } catch {
372
+ return {
373
+ answers: [],
374
+ lastMessageId,
375
+ fileSize: 0,
376
+ };
377
+ }
378
+ }
379
+
380
+ module.exports = {
381
+ detectAgent,
382
+ parseClaudeFinalLine,
383
+ parseCodexFinalLine,
384
+ readClaudeAnswers,
385
+ readCodexAnswers,
386
+ readGeminiAnswers,
387
+ selectSessionCandidatePath,
388
+ selectClaudeSessionFile,
389
+ selectCodexSessionFile,
390
+ selectGeminiSessionFile,
391
+ };
package/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const { BRAND, usage } = require("./util");
2
- const { ArmedSeat, stopAllSessions } = require("./runtime");
2
+ const { ArmedSeat, getStatusReport, stopAllSessions } = require("./runtime");
3
3
 
4
4
  async function main(argv = process.argv.slice(2)) {
5
5
  if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
@@ -24,7 +24,31 @@ async function main(argv = process.argv.slice(2)) {
24
24
  for (const session of result.sessions) {
25
25
  process.stdout.write(`${session.sessionName}\n`);
26
26
  for (const seat of session.seats) {
27
- process.stdout.write(`seat ${seat.seatId}: wrapper ${describeStopResult(seat.wrapperStopped)} · child ${describeStopResult(seat.childStopped)}\n`);
27
+ process.stdout.write(`seat ${seat.seatId}: ${seat.state} · agent ${seat.agent || "idle"} · relays ${seat.relayCount}\n`);
28
+ }
29
+ }
30
+ return;
31
+ }
32
+
33
+ if (command === "status") {
34
+ if (argv.length > 1) {
35
+ throw new Error("`muuuuse status` takes no extra arguments.");
36
+ }
37
+
38
+ const report = getStatusReport();
39
+ if (report.sessions.length === 0) {
40
+ process.stdout.write(`${BRAND} no armed seats found.\n`);
41
+ return;
42
+ }
43
+
44
+ process.stdout.write(`${BRAND} status\n`);
45
+ for (const session of report.sessions) {
46
+ process.stdout.write(`\n${session.sessionName}\n`);
47
+ if (session.stopRequestedAt) {
48
+ process.stdout.write(`stop requested: ${session.stopRequestedAt}\n`);
49
+ }
50
+ for (const seat of session.seats) {
51
+ process.stdout.write(renderSeatStatus(seat));
28
52
  }
29
53
  }
30
54
  return;
@@ -32,7 +56,7 @@ async function main(argv = process.argv.slice(2)) {
32
56
 
33
57
  if (command === "1" || command === "2") {
34
58
  if (argv.length > 1) {
35
- throw new Error(`\`muuuuse ${command}\` no longer takes a program. It arms this terminal raw.`);
59
+ throw new Error(`\`muuuuse ${command}\` takes no extra arguments. Run it directly in the terminal you want to arm.`);
36
60
  }
37
61
 
38
62
  const seat = new ArmedSeat({
@@ -46,8 +70,30 @@ async function main(argv = process.argv.slice(2)) {
46
70
  throw new Error(`Unknown command '${command}'.`);
47
71
  }
48
72
 
49
- function describeStopResult(signaled) {
50
- return signaled ? "signaled" : "idle";
73
+ function renderSeatStatus(seat) {
74
+ const bits = [
75
+ `seat ${seat.seatId}: ${seat.state}`,
76
+ `agent ${seat.agent || "idle"}`,
77
+ `relays ${seat.relayCount}`,
78
+ `wrapper ${seat.wrapperPid || "-"}`,
79
+ `child ${seat.childPid || "-"}`,
80
+ ];
81
+
82
+ if (seat.partnerLive) {
83
+ bits.push("peer live");
84
+ }
85
+ if (seat.lastAnswerAt) {
86
+ bits.push(`last answer ${seat.lastAnswerAt}`);
87
+ }
88
+
89
+ let output = `${bits.join(" · ")}\n`;
90
+ if (seat.cwd) {
91
+ output += `cwd: ${seat.cwd}\n`;
92
+ }
93
+ if (seat.log) {
94
+ output += `log: ${seat.log}\n`;
95
+ }
96
+ return output;
51
97
  }
52
98
 
53
99
  module.exports = {