muuuuse 1.5.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": "1.5.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
@@ -12,9 +12,11 @@ const {
12
12
  } = require("./util");
13
13
 
14
14
  const CODEX_ROOT = path.join(os.homedir(), ".codex", "sessions");
15
+ const CODEX_SNAPSHOT_ROOT = path.join(os.homedir(), ".codex", "shell_snapshots");
15
16
  const CLAUDE_ROOT = path.join(os.homedir(), ".claude", "projects");
16
17
  const GEMINI_ROOT = path.join(os.homedir(), ".gemini", "tmp");
17
18
  const SESSION_START_EARLY_TOLERANCE_MS = 2 * 1000;
19
+ const STRICT_SINGLE_CANDIDATE_EARLY_TOLERANCE_MS = 250;
18
20
 
19
21
  function walkFiles(rootPath, predicate, results = []) {
20
22
  try {
@@ -51,7 +53,11 @@ function buildDetectedAgent(type, process) {
51
53
  }
52
54
 
53
55
  function detectAgent(processes) {
54
- const ordered = [...processes].sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
56
+ const ordered = [...processes].sort((left, right) => (
57
+ (left.depth ?? Number.MAX_SAFE_INTEGER) - (right.depth ?? Number.MAX_SAFE_INTEGER) ||
58
+ right.elapsedSeconds - left.elapsedSeconds ||
59
+ left.pid - right.pid
60
+ ));
55
61
  for (const process of ordered) {
56
62
  if (commandMatches(process.args, "codex")) {
57
63
  return buildDetectedAgent("codex", process);
@@ -93,6 +99,33 @@ function readFirstLines(filePath, maxLines = 20) {
93
99
  }
94
100
  }
95
101
 
102
+ function sortSessionCandidates(candidates) {
103
+ return candidates
104
+ .slice()
105
+ .sort((left, right) => {
106
+ const leftDiff = Number.isFinite(left.diffMs) ? left.diffMs : Number.MAX_SAFE_INTEGER;
107
+ const rightDiff = Number.isFinite(right.diffMs) ? right.diffMs : Number.MAX_SAFE_INTEGER;
108
+ return (
109
+ leftDiff - rightDiff ||
110
+ right.startedAtMs - left.startedAtMs ||
111
+ right.mtimeMs - left.mtimeMs ||
112
+ left.path.localeCompare(right.path)
113
+ );
114
+ });
115
+ }
116
+
117
+ function annotateSessionCandidates(candidates, processStartedAtMs) {
118
+ return candidates.map((candidate) => ({
119
+ ...candidate,
120
+ diffMs: Number.isFinite(processStartedAtMs) && Number.isFinite(candidate.startedAtMs)
121
+ ? Math.abs(candidate.startedAtMs - processStartedAtMs)
122
+ : Number.POSITIVE_INFINITY,
123
+ relativeStartMs: Number.isFinite(processStartedAtMs) && Number.isFinite(candidate.startedAtMs)
124
+ ? candidate.startedAtMs - processStartedAtMs
125
+ : Number.NaN,
126
+ }));
127
+ }
128
+
96
129
  function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs) {
97
130
  const cwdMatches = candidates.filter((candidate) => candidate.cwd === currentPath);
98
131
  if (cwdMatches.length === 0) {
@@ -107,12 +140,7 @@ function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs)
107
140
  return null;
108
141
  }
109
142
 
110
- const preciseMatches = cwdMatches
111
- .map((candidate) => ({
112
- ...candidate,
113
- diffMs: Math.abs(candidate.startedAtMs - processStartedAtMs),
114
- relativeStartMs: candidate.startedAtMs - processStartedAtMs,
115
- }))
143
+ const preciseMatches = annotateSessionCandidates(cwdMatches, processStartedAtMs)
116
144
  .filter((candidate) => (
117
145
  Number.isFinite(candidate.diffMs) &&
118
146
  Number.isFinite(candidate.relativeStartMs) &&
@@ -128,29 +156,98 @@ function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs)
128
156
  return null;
129
157
  }
130
158
 
131
- function listOpenFilePaths(pid, rootPath) {
132
- if (!Number.isInteger(pid) || pid <= 0) {
133
- return [];
159
+ function readCodexSeatClaim(sessionId) {
160
+ if (!sessionId) {
161
+ return null;
134
162
  }
135
163
 
136
- const fdRoot = `/proc/${pid}/fd`;
164
+ const snapshotPath = path.join(CODEX_SNAPSHOT_ROOT, `${sessionId}.sh`);
137
165
  try {
138
- const rootPrefix = path.resolve(rootPath);
139
- const openPaths = fs.readdirSync(fdRoot)
140
- .map((entry) => {
141
- try {
142
- return fs.realpathSync(path.join(fdRoot, entry));
143
- } catch {
144
- return null;
145
- }
146
- })
147
- .filter((entry) => typeof entry === "string")
148
- .filter((entry) => entry.startsWith(rootPrefix));
166
+ const text = fs.readFileSync(snapshotPath, "utf8");
167
+ const seatMatch = text.match(/declare -x MUUUUSE_SEAT="([^"]+)"/);
168
+ const sessionMatch = text.match(/declare -x MUUUUSE_SESSION="([^"]+)"/);
169
+ if (!seatMatch || !sessionMatch) {
170
+ return null;
171
+ }
149
172
 
150
- return [...new Set(openPaths)];
173
+ return {
174
+ seatId: seatMatch[1],
175
+ sessionName: sessionMatch[1],
176
+ };
151
177
  } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function selectClaimedCodexCandidatePath(candidates, options = {}) {
183
+ const seatId = options.seatId == null ? null : String(options.seatId);
184
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
185
+ if (!seatId || !sessionName || candidates.length <= 1) {
186
+ return null;
187
+ }
188
+
189
+ const annotated = candidates.map((candidate) => ({
190
+ ...candidate,
191
+ claim: readCodexSeatClaim(candidate.sessionId),
192
+ }));
193
+
194
+ const exactMatches = annotated.filter((candidate) => (
195
+ candidate.claim?.seatId === seatId &&
196
+ candidate.claim?.sessionName === sessionName
197
+ ));
198
+ if (exactMatches.length === 1) {
199
+ return exactMatches[0].path;
200
+ }
201
+
202
+ const otherSeatClaims = annotated.filter((candidate) => (
203
+ candidate.claim?.sessionName === sessionName &&
204
+ candidate.claim?.seatId !== seatId
205
+ ));
206
+ if (otherSeatClaims.length === 0) {
207
+ return null;
208
+ }
209
+
210
+ const foreignPaths = new Set(otherSeatClaims.map((candidate) => candidate.path));
211
+ const remaining = annotated.filter((candidate) => !foreignPaths.has(candidate.path));
212
+ if (remaining.length === 1) {
213
+ return remaining[0].path;
214
+ }
215
+
216
+ return null;
217
+ }
218
+
219
+ function listOpenFilePathsForPids(pids, rootPath) {
220
+ const normalizedPids = [...new Set(
221
+ (Array.isArray(pids) ? pids : [pids])
222
+ .map((pid) => Number.parseInt(pid, 10))
223
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
224
+ )];
225
+ if (normalizedPids.length === 0) {
152
226
  return [];
153
227
  }
228
+
229
+ const rootPrefix = path.resolve(rootPath);
230
+ const openPaths = new Set();
231
+
232
+ for (const pid of normalizedPids) {
233
+ const fdRoot = `/proc/${pid}/fd`;
234
+ try {
235
+ for (const entry of fs.readdirSync(fdRoot)) {
236
+ try {
237
+ const resolved = fs.realpathSync(path.join(fdRoot, entry));
238
+ if (typeof resolved === "string" && resolved.startsWith(rootPrefix)) {
239
+ openPaths.add(resolved);
240
+ }
241
+ } catch {
242
+ // Ignore descriptors that disappear while we are inspecting them.
243
+ }
244
+ }
245
+ } catch {
246
+ // Ignore pids that have already exited.
247
+ }
248
+ }
249
+
250
+ return [...openPaths];
154
251
  }
155
252
 
156
253
  function selectLiveSessionCandidatePath(candidates, currentPath, captureSinceMs = null) {
@@ -173,8 +270,8 @@ function selectLiveSessionCandidatePath(candidates, currentPath, captureSinceMs
173
270
  return ranked[0]?.path || null;
174
271
  }
175
272
 
176
- function readOpenSessionCandidates(pid, rootPath, reader) {
177
- return listOpenFilePaths(pid, rootPath)
273
+ function readOpenSessionCandidates(pids, rootPath, reader) {
274
+ return listOpenFilePathsForPids(pids, rootPath)
178
275
  .map((filePath) => reader(filePath))
179
276
  .filter((candidate) => candidate !== null);
180
277
  }
@@ -195,6 +292,7 @@ function readCodexCandidate(filePath) {
195
292
  path: filePath,
196
293
  cwd: entry.payload.cwd,
197
294
  isSubagent: Boolean(entry.payload?.source?.subagent),
295
+ sessionId: entry.payload.id || null,
198
296
  startedAtMs: Date.parse(entry.payload.timestamp),
199
297
  mtimeMs: fs.statSync(filePath).mtimeMs,
200
298
  };
@@ -203,9 +301,131 @@ function readCodexCandidate(filePath) {
203
301
  }
204
302
  }
205
303
 
304
+ function rankCodexCandidates(candidates, processStartedAtMs) {
305
+ return sortSessionCandidates(annotateSessionCandidates(candidates, processStartedAtMs));
306
+ }
307
+
308
+ function selectExactClaimedCodexCandidate(candidates, options = {}, processStartedAtMs = null) {
309
+ const seatId = options.seatId == null ? null : String(options.seatId);
310
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
311
+ if (!seatId || !sessionName) {
312
+ return null;
313
+ }
314
+
315
+ const exactMatches = rankCodexCandidates(
316
+ candidates.filter((candidate) => {
317
+ const claim = readCodexSeatClaim(candidate.sessionId);
318
+ return claim?.seatId === seatId && claim?.sessionName === sessionName;
319
+ }),
320
+ processStartedAtMs
321
+ );
322
+
323
+ return exactMatches[0]?.path || null;
324
+ }
325
+
326
+ function filterForeignClaimedCodexCandidates(candidates, options = {}) {
327
+ const seatId = options.seatId == null ? null : String(options.seatId);
328
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
329
+ if (!seatId || !sessionName) {
330
+ return candidates.slice();
331
+ }
332
+
333
+ return candidates.filter((candidate) => {
334
+ const claim = readCodexSeatClaim(candidate.sessionId);
335
+ return !(claim?.sessionName === sessionName && claim?.seatId && claim.seatId !== seatId);
336
+ });
337
+ }
338
+
339
+ function selectStrictSingleCodexCandidatePath(candidates, processStartedAtMs) {
340
+ if (candidates.length !== 1 || !Number.isFinite(processStartedAtMs)) {
341
+ return null;
342
+ }
343
+
344
+ const [candidate] = annotateSessionCandidates(candidates, processStartedAtMs);
345
+ if (!Number.isFinite(candidate.relativeStartMs)) {
346
+ return null;
347
+ }
348
+
349
+ if (
350
+ candidate.relativeStartMs < -STRICT_SINGLE_CANDIDATE_EARLY_TOLERANCE_MS ||
351
+ candidate.relativeStartMs > SESSION_MATCH_WINDOW_MS
352
+ ) {
353
+ return null;
354
+ }
355
+
356
+ return candidate.path;
357
+ }
358
+
359
+ function selectCodexCandidatePath(candidates, currentPath, processStartedAtMs, options = {}) {
360
+ const cwdMatches = candidates.filter((candidate) => candidate.cwd === currentPath);
361
+ if (cwdMatches.length === 0) {
362
+ return null;
363
+ }
364
+
365
+ const seatId = options.seatId == null ? null : String(options.seatId);
366
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
367
+ const exactClaimPath = selectExactClaimedCodexCandidate(cwdMatches, options, processStartedAtMs);
368
+ if (exactClaimPath) {
369
+ return exactClaimPath;
370
+ }
371
+
372
+ const foreignClaimsPresent = Boolean(
373
+ seatId &&
374
+ sessionName &&
375
+ cwdMatches.some((candidate) => {
376
+ const claim = readCodexSeatClaim(candidate.sessionId);
377
+ return claim?.sessionName === sessionName && claim?.seatId && claim.seatId !== seatId;
378
+ })
379
+ );
380
+ const allowedMatches = filterForeignClaimedCodexCandidates(cwdMatches, options);
381
+ if (allowedMatches.length === 0) {
382
+ return null;
383
+ }
384
+
385
+ if (!Number.isFinite(processStartedAtMs)) {
386
+ return allowedMatches.length === 1 ? allowedMatches[0].path : null;
387
+ }
388
+
389
+ const preciseMatches = rankCodexCandidates(
390
+ allowedMatches.filter((candidate) => {
391
+ const annotated = annotateSessionCandidates([candidate], processStartedAtMs)[0];
392
+ return (
393
+ Number.isFinite(annotated.diffMs) &&
394
+ Number.isFinite(annotated.relativeStartMs) &&
395
+ annotated.relativeStartMs >= -SESSION_START_EARLY_TOLERANCE_MS &&
396
+ annotated.relativeStartMs <= SESSION_MATCH_WINDOW_MS
397
+ );
398
+ }),
399
+ processStartedAtMs
400
+ );
401
+
402
+ const preciseClaimPath = selectClaimedCodexCandidatePath(preciseMatches, options);
403
+ if (preciseClaimPath) {
404
+ return preciseClaimPath;
405
+ }
406
+
407
+ const pairedSeatSelection = seatId && sessionName;
408
+ if (pairedSeatSelection && options.allowUnclaimedSingleCandidate === false && !foreignClaimsPresent) {
409
+ return null;
410
+ }
411
+
412
+ if (preciseMatches.length === 1) {
413
+ return selectStrictSingleCodexCandidatePath(preciseMatches, processStartedAtMs);
414
+ }
415
+
416
+ if (allowedMatches.length === 1) {
417
+ return selectStrictSingleCodexCandidatePath(allowedMatches, processStartedAtMs);
418
+ }
419
+
420
+ return null;
421
+ }
422
+
206
423
  function selectCodexSessionFile(currentPath, processStartedAtMs, options = {}) {
207
- const liveCandidates = readOpenSessionCandidates(options.pid, CODEX_ROOT, readCodexCandidate);
208
- const livePath = selectLiveSessionCandidatePath(liveCandidates, currentPath, options.captureSinceMs);
424
+ const liveCandidates = readOpenSessionCandidates(options.pids ?? options.pid, CODEX_ROOT, readCodexCandidate);
425
+ const livePath = selectCodexCandidatePath(liveCandidates, currentPath, processStartedAtMs, {
426
+ ...options,
427
+ allowUnclaimedSingleCandidate: true,
428
+ });
209
429
  if (livePath) {
210
430
  return livePath;
211
431
  }
@@ -214,7 +434,10 @@ function selectCodexSessionFile(currentPath, processStartedAtMs, options = {}) {
214
434
  .map((filePath) => readCodexCandidate(filePath))
215
435
  .filter((candidate) => candidate !== null);
216
436
 
217
- return selectSessionCandidatePath(candidates, currentPath, processStartedAtMs);
437
+ return selectCodexCandidatePath(candidates, currentPath, processStartedAtMs, {
438
+ ...options,
439
+ allowUnclaimedSingleCandidate: false,
440
+ });
218
441
  }
219
442
 
220
443
  function extractCodexAssistantText(content) {
@@ -236,14 +459,17 @@ function extractCodexAssistantText(content) {
236
459
  .join("\n");
237
460
  }
238
461
 
239
- function parseCodexFinalLine(line) {
462
+ function parseCodexAssistantLine(line, options = {}) {
463
+ const flowMode = options.flowMode === true;
240
464
  try {
241
465
  const entry = JSON.parse(line);
242
466
  if (entry?.type !== "response_item" || entry.payload?.type !== "message" || entry.payload?.role !== "assistant") {
243
467
  return null;
244
468
  }
245
469
 
246
- 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) {
247
473
  return null;
248
474
  }
249
475
 
@@ -262,6 +488,10 @@ function parseCodexFinalLine(line) {
262
488
  }
263
489
  }
264
490
 
491
+ function parseCodexFinalLine(line) {
492
+ return parseCodexAssistantLine(line, { flowMode: false });
493
+ }
494
+
265
495
  function isAnswerNewEnough(answer, sinceMs = null) {
266
496
  if (!Number.isFinite(sinceMs)) {
267
497
  return true;
@@ -275,13 +505,13 @@ function isAnswerNewEnough(answer, sinceMs = null) {
275
505
  return answerMs >= sinceMs;
276
506
  }
277
507
 
278
- function readCodexAnswers(filePath, offset, sinceMs = null) {
508
+ function readCodexAnswers(filePath, offset, sinceMs = null, options = {}) {
279
509
  const { nextOffset, text } = readAppendedText(filePath, offset);
280
510
  const answers = text
281
511
  .split("\n")
282
512
  .map((line) => line.trim())
283
513
  .filter((line) => line.length > 0)
284
- .map((line) => parseCodexFinalLine(line))
514
+ .map((line) => parseCodexAssistantLine(line, options))
285
515
  .filter((entry) => entry !== null)
286
516
  .filter((entry) => isAnswerNewEnough(entry, sinceMs));
287
517
 
@@ -311,7 +541,7 @@ function readClaudeCandidate(filePath) {
311
541
  }
312
542
 
313
543
  function selectClaudeSessionFile(currentPath, processStartedAtMs, options = {}) {
314
- const liveCandidates = readOpenSessionCandidates(options.pid, CLAUDE_ROOT, readClaudeCandidate);
544
+ const liveCandidates = readOpenSessionCandidates(options.pids ?? options.pid, CLAUDE_ROOT, readClaudeCandidate);
315
545
  const livePath = selectLiveSessionCandidatePath(liveCandidates, currentPath, options.captureSinceMs);
316
546
  if (livePath) {
317
547
  return livePath;
@@ -324,7 +554,8 @@ function selectClaudeSessionFile(currentPath, processStartedAtMs, options = {})
324
554
  return selectSessionCandidatePath(candidates, currentPath, processStartedAtMs);
325
555
  }
326
556
 
327
- function extractClaudeAssistantText(content) {
557
+ function extractClaudeAssistantText(content, options = {}) {
558
+ const flowMode = options.flowMode === true;
328
559
  if (!Array.isArray(content)) {
329
560
  return "";
330
561
  }
@@ -337,20 +568,28 @@ function extractClaudeAssistantText(content) {
337
568
  if (item.type === "text" && typeof item.text === "string") {
338
569
  return [item.text.trim()];
339
570
  }
571
+ if (flowMode && item.type === "thinking" && typeof item.thinking === "string") {
572
+ return [item.thinking.trim()];
573
+ }
340
574
  return [];
341
575
  })
342
576
  .filter((text) => text.length > 0)
343
577
  .join("\n");
344
578
  }
345
579
 
346
- function parseClaudeFinalLine(line) {
580
+ function parseClaudeAssistantLine(line, options = {}) {
581
+ const flowMode = options.flowMode === true;
347
582
  try {
348
583
  const entry = JSON.parse(line);
349
- if (entry?.type !== "assistant" || entry.message?.role !== "assistant" || entry.message?.stop_reason !== "end_turn") {
584
+ if (entry?.type !== "assistant" || entry.message?.role !== "assistant") {
350
585
  return null;
351
586
  }
352
587
 
353
- 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));
354
593
  if (!text) {
355
594
  return null;
356
595
  }
@@ -365,13 +604,17 @@ function parseClaudeFinalLine(line) {
365
604
  }
366
605
  }
367
606
 
368
- 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 = {}) {
369
612
  const { nextOffset, text } = readAppendedText(filePath, offset);
370
613
  const answers = text
371
614
  .split("\n")
372
615
  .map((line) => line.trim())
373
616
  .filter((line) => line.length > 0)
374
- .map((line) => parseClaudeFinalLine(line))
617
+ .map((line) => parseClaudeAssistantLine(line, options))
375
618
  .filter((entry) => entry !== null)
376
619
  .filter((entry) => isAnswerNewEnough(entry, sinceMs));
377
620
 
@@ -397,7 +640,7 @@ function readGeminiCandidate(filePath) {
397
640
 
398
641
  function selectGeminiSessionFile(currentPath, processStartedAtMs, options = {}) {
399
642
  const projectHash = createHash("sha256").update(currentPath).digest("hex");
400
- const liveCandidates = readOpenSessionCandidates(options.pid, GEMINI_ROOT, readGeminiCandidate)
643
+ const liveCandidates = readOpenSessionCandidates(options.pids ?? options.pid, GEMINI_ROOT, readGeminiCandidate)
401
644
  .filter((candidate) => candidate.projectHash === projectHash);
402
645
  const livePath = selectLiveSessionCandidatePath(liveCandidates, projectHash, options.captureSinceMs);
403
646
  if (livePath) {
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
@@ -1,5 +1,6 @@
1
1
  const fs = require("node:fs");
2
2
  const { execFileSync } = require("node:child_process");
3
+ const path = require("node:path");
3
4
  const pty = require("node-pty");
4
5
 
5
6
  const {
@@ -36,8 +37,23 @@ const {
36
37
 
37
38
  const TYPE_DELAY_MS = 70;
38
39
  const MIRROR_SUPPRESSION_WINDOW_MS = 30 * 1000;
40
+ const PENDING_RELAY_CONTEXT_TTL_MS = 2 * 60 * 1000;
41
+ const EMITTED_ANSWER_TTL_MS = 5 * 60 * 1000;
39
42
  const MAX_RECENT_INBOUND_RELAYS = 12;
43
+ const MAX_RECENT_EMITTED_ANSWERS = 48;
44
+ const MAX_RELAY_CHAIN_HOP = 1;
40
45
  const STOP_FORCE_KILL_MS = 1200;
46
+ const SEAT_JOIN_WAIT_MS = 3000;
47
+ const SEAT_JOIN_POLL_MS = 60;
48
+ const CHILD_ENV_DROP_KEYS = [
49
+ "CODEX_CI",
50
+ "CODEX_MANAGED_BY_NPM",
51
+ "CODEX_THREAD_ID",
52
+ ];
53
+
54
+ function normalizeFlowMode(flowMode) {
55
+ return String(flowMode || "").trim().toLowerCase() === "on" ? "on" : "off";
56
+ }
41
57
 
42
58
  function resolveShell() {
43
59
  const shell = String(process.env.SHELL || "").trim();
@@ -52,16 +68,139 @@ function resolveShellArgs(shellPath) {
52
68
  return [];
53
69
  }
54
70
 
55
- function resolveChildTerm() {
56
- const inherited = String(process.env.TERM || "").trim();
71
+ function resolveChildTerm(sourceEnv = process.env) {
72
+ const inherited = String(sourceEnv.TERM || "").trim();
57
73
  if (inherited && inherited.toLowerCase() !== "dumb") {
58
74
  return inherited;
59
75
  }
60
76
  return "xterm-256color";
61
77
  }
62
78
 
63
- function resolveSessionName(currentPath = process.cwd()) {
64
- return getDefaultSessionName(currentPath);
79
+ function sanitizeChildPath(pathValue, homeDir) {
80
+ const arg0Root = path.join(homeDir, ".codex", "tmp", "arg0");
81
+ const entries = String(pathValue || "")
82
+ .split(path.delimiter)
83
+ .filter(Boolean)
84
+ .filter((entry) => {
85
+ const resolved = path.resolve(entry);
86
+ return resolved !== arg0Root && !resolved.startsWith(`${arg0Root}${path.sep}`);
87
+ });
88
+
89
+ return entries.join(path.delimiter);
90
+ }
91
+
92
+ function buildChildEnv(seatId, sessionName, cwd, baseEnv = process.env) {
93
+ const env = { ...baseEnv };
94
+ for (const key of CHILD_ENV_DROP_KEYS) {
95
+ delete env[key];
96
+ }
97
+
98
+ const homeDir = String(env.HOME || "").trim() || process.env.HOME || "/root";
99
+ env.PATH = sanitizeChildPath(env.PATH, homeDir);
100
+ env.PWD = cwd;
101
+ env.TERM = resolveChildTerm(baseEnv);
102
+ env.MUUUUSE_SEAT = String(seatId);
103
+ env.MUUUUSE_SESSION = sessionName;
104
+ return env;
105
+ }
106
+
107
+ function normalizeWorkingPath(currentPath = process.cwd()) {
108
+ try {
109
+ return fs.realpathSync(currentPath);
110
+ } catch {
111
+ return path.resolve(currentPath);
112
+ }
113
+ }
114
+
115
+ function matchesWorkingPath(leftPath, rightPath) {
116
+ if (!leftPath || !rightPath) {
117
+ return false;
118
+ }
119
+
120
+ return normalizeWorkingPath(leftPath) === normalizeWorkingPath(rightPath);
121
+ }
122
+
123
+ function createSessionName(currentPath = process.cwd()) {
124
+ return `${getDefaultSessionName(currentPath)}-${createId(6)}`;
125
+ }
126
+
127
+ function sleepSync(ms) {
128
+ if (!Number.isFinite(ms) || ms <= 0) {
129
+ return;
130
+ }
131
+
132
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
133
+ }
134
+
135
+ function findJoinableSessionName(currentPath = process.cwd()) {
136
+ const candidates = listSessionNames()
137
+ .map((sessionName) => {
138
+ const sessionPaths = getSessionPaths(sessionName);
139
+ const controller = readJson(sessionPaths.controllerPath, null);
140
+ const seat1Paths = getSeatPaths(sessionName, 1);
141
+ const seat2Paths = getSeatPaths(sessionName, 2);
142
+ const seat1Meta = readJson(seat1Paths.metaPath, null);
143
+ const seat1Status = readJson(seat1Paths.statusPath, null);
144
+ const seat2Meta = readJson(seat2Paths.metaPath, null);
145
+ const seat2Status = readJson(seat2Paths.statusPath, null);
146
+ const stopRequest = readJson(sessionPaths.stopPath, null);
147
+
148
+ const cwd = controller?.cwd || seat1Status?.cwd || seat1Meta?.cwd || seat2Status?.cwd || seat2Meta?.cwd || null;
149
+ if (!matchesWorkingPath(cwd, currentPath)) {
150
+ return null;
151
+ }
152
+
153
+ const seat1WrapperPid = seat1Status?.pid || seat1Meta?.pid || null;
154
+ const seat1ChildPid = seat1Status?.childPid || seat1Meta?.childPid || null;
155
+ const seat2WrapperPid = seat2Status?.pid || seat2Meta?.pid || null;
156
+ const seat2ChildPid = seat2Status?.childPid || seat2Meta?.childPid || null;
157
+ const seat1Live = isPidAlive(seat1WrapperPid) || isPidAlive(seat1ChildPid);
158
+ const seat2Live = isPidAlive(seat2WrapperPid) || isPidAlive(seat2ChildPid);
159
+ const stopRequestedAtMs = Date.parse(stopRequest?.requestedAt || "");
160
+ const createdAtMs = Date.parse(controller?.createdAt || seat1Meta?.startedAt || seat1Status?.updatedAt || "");
161
+
162
+ if (!seat1Live || seat2Live) {
163
+ return null;
164
+ }
165
+
166
+ if (Number.isFinite(stopRequestedAtMs) && Number.isFinite(createdAtMs) && stopRequestedAtMs > createdAtMs) {
167
+ return null;
168
+ }
169
+
170
+ return {
171
+ sessionName,
172
+ createdAtMs,
173
+ };
174
+ })
175
+ .filter((entry) => entry !== null)
176
+ .sort((left, right) => right.createdAtMs - left.createdAtMs);
177
+
178
+ return candidates[0]?.sessionName || null;
179
+ }
180
+
181
+ function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEAT_JOIN_WAIT_MS) {
182
+ const deadline = Date.now() + timeoutMs;
183
+ while (Date.now() <= deadline) {
184
+ const sessionName = findJoinableSessionName(currentPath);
185
+ if (sessionName) {
186
+ return sessionName;
187
+ }
188
+ sleepSync(SEAT_JOIN_POLL_MS);
189
+ }
190
+
191
+ return null;
192
+ }
193
+
194
+ function resolveSessionName(currentPath = process.cwd(), seatId = 1) {
195
+ if (seatId === 1) {
196
+ return createSessionName(currentPath);
197
+ }
198
+
199
+ if (seatId === 2) {
200
+ return waitForJoinableSessionName(currentPath);
201
+ }
202
+
203
+ return createSessionName(currentPath);
65
204
  }
66
205
 
67
206
  function parseAnswerEntries(text) {
@@ -79,6 +218,51 @@ function parseAnswerEntries(text) {
79
218
  .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
80
219
  }
81
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
+
82
266
  function readProcessCwd(pid) {
83
267
  if (!Number.isInteger(pid) || pid <= 0) {
84
268
  return null;
@@ -121,20 +305,25 @@ function getChildProcesses(rootPid) {
121
305
  .filter((entry) => entry !== null);
122
306
 
123
307
  const descendants = [];
124
- const queue = [rootPid];
125
- const seen = new Set(queue);
308
+ const queue = [{ pid: rootPid, depth: 0 }];
309
+ const seen = new Set([rootPid]);
126
310
 
127
311
  while (queue.length > 0) {
128
- const parentPid = queue.shift();
312
+ const current = queue.shift();
313
+ const parentPid = current.pid;
129
314
  for (const process of processes) {
130
315
  if (process.ppid !== parentPid || seen.has(process.pid)) {
131
316
  continue;
132
317
  }
133
318
  seen.add(process.pid);
134
- queue.push(process.pid);
319
+ queue.push({
320
+ pid: process.pid,
321
+ depth: current.depth + 1,
322
+ });
135
323
  descendants.push({
136
324
  ...process,
137
325
  cwd: readProcessCwd(process.pid),
326
+ depth: current.depth + 1,
138
327
  });
139
328
  }
140
329
  }
@@ -145,14 +334,40 @@ function getChildProcesses(rootPid) {
145
334
  }
146
335
  }
147
336
 
148
- function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, processStartedAtMs) {
337
+ function getProcessFamilyPids(processes, rootPid) {
338
+ if (!Number.isInteger(rootPid) || rootPid <= 0) {
339
+ return [];
340
+ }
341
+
342
+ const related = new Set([rootPid]);
343
+ const queue = [rootPid];
344
+
345
+ while (queue.length > 0) {
346
+ const parentPid = queue.shift();
347
+ for (const process of processes) {
348
+ if (process.ppid !== parentPid || related.has(process.pid)) {
349
+ continue;
350
+ }
351
+
352
+ related.add(process.pid);
353
+ queue.push(process.pid);
354
+ }
355
+ }
356
+
357
+ return [...related];
358
+ }
359
+
360
+ function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, processStartedAtMs, seatContext = {}) {
149
361
  if (!currentPath) {
150
362
  return null;
151
363
  }
152
364
 
153
365
  const options = {
154
366
  pid: agentPid,
367
+ pids: seatContext.agentPids,
155
368
  captureSinceMs,
369
+ seatId: seatContext.seatId,
370
+ sessionName: seatContext.sessionName,
156
371
  };
157
372
 
158
373
  if (agentType === "codex") {
@@ -192,6 +407,8 @@ function buildAnswerSignaturePayload(sessionName, challenge, entry) {
192
407
  type: "muuuuse_answer",
193
408
  sessionName,
194
409
  challenge,
410
+ chainId: entry.chainId,
411
+ hop: entry.hop,
195
412
  id: entry.id,
196
413
  seatId: entry.seatId,
197
414
  origin: entry.origin,
@@ -219,35 +436,23 @@ function readSeatChallenge(paths, sessionName) {
219
436
  }
220
437
 
221
438
  async function sendTextAndEnter(child, text, shouldAbort = () => false) {
222
- 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();
223
444
 
224
- for (let index = 0; index < lines.length; index += 1) {
445
+ if (payload.length > 0) {
225
446
  if (shouldAbort() || !child) {
226
447
  return false;
227
448
  }
228
449
 
229
- const line = lines[index];
230
- if (line.length > 0) {
231
- try {
232
- child.write(line);
233
- } catch {
234
- return false;
235
- }
236
- await sleep(TYPE_DELAY_MS);
237
- }
238
-
239
- if (index < lines.length - 1) {
240
- if (shouldAbort()) {
241
- return false;
242
- }
243
-
244
- try {
245
- child.write("\r");
246
- } catch {
247
- return false;
248
- }
249
- await sleep(TYPE_DELAY_MS);
450
+ try {
451
+ child.write(payload);
452
+ } catch {
453
+ return false;
250
454
  }
455
+ await sleep(TYPE_DELAY_MS);
251
456
  }
252
457
 
253
458
  if (shouldAbort() || !child) {
@@ -267,8 +472,12 @@ class ArmedSeat {
267
472
  constructor(options) {
268
473
  this.seatId = options.seatId;
269
474
  this.partnerSeatId = options.seatId === 1 ? 2 : 1;
270
- this.cwd = options.cwd;
271
- this.sessionName = resolveSessionName(this.cwd);
475
+ this.flowMode = normalizeFlowMode(options.flowMode);
476
+ this.cwd = normalizeWorkingPath(options.cwd);
477
+ this.sessionName = resolveSessionName(this.cwd, this.seatId);
478
+ if (!this.sessionName) {
479
+ throw new Error("No armed `muuuuse 1` seat is waiting in this cwd. Run `muuuuse 1` first.");
480
+ }
272
481
  this.sessionPaths = getSessionPaths(this.sessionName);
273
482
  this.paths = getSeatPaths(this.sessionName, this.seatId);
274
483
  this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
@@ -286,7 +495,10 @@ class ArmedSeat {
286
495
  this.resizeCleanup = null;
287
496
  this.forceKillTimer = null;
288
497
  this.identity = null;
498
+ this.lastUserInputAtMs = 0;
499
+ this.pendingInboundContext = null;
289
500
  this.recentInboundRelays = [];
501
+ this.recentEmittedAnswers = [];
290
502
  this.trustState = {
291
503
  challenge: null,
292
504
  peerPublicKey: null,
@@ -306,6 +518,20 @@ class ArmedSeat {
306
518
  };
307
519
  }
308
520
 
521
+ writeController(extra = {}) {
522
+ const current = readJson(this.sessionPaths.controllerPath, {});
523
+ writeJson(this.sessionPaths.controllerPath, {
524
+ sessionName: this.sessionName,
525
+ cwd: this.cwd,
526
+ createdAt: current.createdAt || this.startedAt,
527
+ updatedAt: new Date().toISOString(),
528
+ seat1Pid: this.seatId === 1 ? process.pid : current.seat1Pid || null,
529
+ seat2Pid: this.seatId === 2 ? process.pid : current.seat2Pid || null,
530
+ pid: this.seatId === 1 ? process.pid : current.pid || null,
531
+ ...extra,
532
+ });
533
+ }
534
+
309
535
  log(message) {
310
536
  process.stderr.write(`${message}\n`);
311
537
  }
@@ -315,6 +541,7 @@ class ArmedSeat {
315
541
  seatId: this.seatId,
316
542
  partnerSeatId: this.partnerSeatId,
317
543
  sessionName: this.sessionName,
544
+ flowMode: this.flowMode,
318
545
  cwd: this.cwd,
319
546
  pid: process.pid,
320
547
  childPid: this.childPid,
@@ -329,6 +556,7 @@ class ArmedSeat {
329
556
  seatId: this.seatId,
330
557
  partnerSeatId: this.partnerSeatId,
331
558
  sessionName: this.sessionName,
559
+ flowMode: this.flowMode,
332
560
  cwd: this.cwd,
333
561
  pid: process.pid,
334
562
  childPid: this.childPid,
@@ -509,20 +737,17 @@ class ArmedSeat {
509
737
  fs.rmSync(this.paths.pipePath, { force: true });
510
738
  clearStaleStopRequest(this.sessionPaths.stopPath, this.startedAtMs);
511
739
  this.initializeTrustMaterial();
740
+ this.writeController();
512
741
 
513
742
  const shell = resolveShell();
514
743
  const shellArgs = resolveShellArgs(shell);
744
+ const childEnv = buildChildEnv(this.seatId, this.sessionName, this.cwd);
515
745
  this.child = pty.spawn(shell, shellArgs, {
516
746
  cols: process.stdout.columns || 120,
517
747
  rows: process.stdout.rows || 36,
518
748
  cwd: this.cwd,
519
- env: {
520
- ...process.env,
521
- TERM: resolveChildTerm(),
522
- MUUUUSE_SEAT: String(this.seatId),
523
- MUUUUSE_SESSION: this.sessionName,
524
- },
525
- name: resolveChildTerm(),
749
+ env: childEnv,
750
+ name: childEnv.TERM,
526
751
  });
527
752
 
528
753
  this.childPid = this.child.pid;
@@ -547,6 +772,8 @@ class ArmedSeat {
547
772
 
548
773
  installStdinProxy() {
549
774
  const handleData = (chunk) => {
775
+ this.lastUserInputAtMs = Date.now();
776
+ this.pendingInboundContext = null;
550
777
  if (!this.child) {
551
778
  return;
552
779
  }
@@ -687,6 +914,8 @@ class ArmedSeat {
687
914
 
688
915
  const payload = sanitizeRelayText(entry.text);
689
916
  const signaturePayload = buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, {
917
+ chainId: entry.chainId || entry.id,
918
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
690
919
  id: entry.id,
691
920
  seatId: entry.seatId,
692
921
  origin: entry.origin || "unknown",
@@ -718,6 +947,14 @@ class ArmedSeat {
718
947
  return;
719
948
  }
720
949
 
950
+ const deliveredAtMs = Date.now();
951
+ this.pendingInboundContext = {
952
+ chainId: entry.chainId || entry.id,
953
+ deliveredAtMs,
954
+ expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
955
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
956
+ relayUsed: false,
957
+ };
721
958
  this.relayCount += 1;
722
959
  this.rememberInboundRelay(payload);
723
960
  this.log(`[${this.partnerSeatId} -> ${this.seatId}] ${previewText(payload)}`);
@@ -749,6 +986,37 @@ class ArmedSeat {
749
986
  );
750
987
  }
751
988
 
989
+ pruneRecentEmittedAnswers(now = Date.now()) {
990
+ this.recentEmittedAnswers = this.recentEmittedAnswers.filter(
991
+ (entry) => now - entry.timestampMs <= EMITTED_ANSWER_TTL_MS
992
+ );
993
+ }
994
+
995
+ hasRecentEmittedAnswer(answerKey) {
996
+ if (!answerKey) {
997
+ return false;
998
+ }
999
+
1000
+ this.pruneRecentEmittedAnswers();
1001
+ return this.recentEmittedAnswers.some((entry) => entry.key === answerKey);
1002
+ }
1003
+
1004
+ rememberEmittedAnswer(answerKey) {
1005
+ if (!answerKey) {
1006
+ return;
1007
+ }
1008
+
1009
+ this.pruneRecentEmittedAnswers();
1010
+ this.recentEmittedAnswers.push({
1011
+ key: answerKey,
1012
+ timestampMs: Date.now(),
1013
+ });
1014
+
1015
+ if (this.recentEmittedAnswers.length > MAX_RECENT_EMITTED_ANSWERS) {
1016
+ this.recentEmittedAnswers = this.recentEmittedAnswers.slice(-MAX_RECENT_EMITTED_ANSWERS);
1017
+ }
1018
+ }
1019
+
752
1020
  takeMirroredInboundRelay(payload) {
753
1021
  const normalized = sanitizeRelayText(payload);
754
1022
  if (!normalized) {
@@ -766,8 +1034,28 @@ class ArmedSeat {
766
1034
  return match;
767
1035
  }
768
1036
 
1037
+ getPendingInboundContext() {
1038
+ const context = this.pendingInboundContext;
1039
+ if (!context) {
1040
+ return null;
1041
+ }
1042
+
1043
+ if (context.expiresAtMs <= Date.now()) {
1044
+ this.pendingInboundContext = null;
1045
+ return null;
1046
+ }
1047
+
1048
+ if (this.lastUserInputAtMs > context.deliveredAtMs) {
1049
+ this.pendingInboundContext = null;
1050
+ return null;
1051
+ }
1052
+
1053
+ return context;
1054
+ }
1055
+
769
1056
  collectLiveAnswers() {
770
- const detectedAgent = detectAgent(getChildProcesses(this.childPid));
1057
+ const childProcesses = getChildProcesses(this.childPid);
1058
+ const detectedAgent = detectAgent(childProcesses);
771
1059
  if (!detectedAgent) {
772
1060
  this.liveState = {
773
1061
  type: null,
@@ -813,18 +1101,27 @@ class ArmedSeat {
813
1101
  };
814
1102
  }
815
1103
 
816
- if (!this.liveState.sessionFile) {
817
- this.liveState.sessionFile = resolveSessionFile(
818
- detectedAgent.type,
819
- detectedAgent.pid,
820
- currentPath,
821
- this.liveState.captureSinceMs,
822
- detectedAgent.processStartedAtMs
823
- );
1104
+ const agentPids = getProcessFamilyPids(childProcesses, detectedAgent.pid);
1105
+ const resolvedSessionFile = resolveSessionFile(
1106
+ detectedAgent.type,
1107
+ detectedAgent.pid,
1108
+ currentPath,
1109
+ this.liveState.captureSinceMs,
1110
+ detectedAgent.processStartedAtMs,
1111
+ {
1112
+ agentPids,
1113
+ seatId: this.seatId,
1114
+ sessionName: this.sessionName,
1115
+ }
1116
+ );
824
1117
 
825
- if (this.liveState.sessionFile) {
826
- this.liveState.offset = 0;
827
- this.liveState.lastMessageId = null;
1118
+ if (resolvedSessionFile && resolvedSessionFile !== this.liveState.sessionFile) {
1119
+ this.liveState.sessionFile = resolvedSessionFile;
1120
+ this.liveState.offset = 0;
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);
828
1125
  }
829
1126
  }
830
1127
 
@@ -843,7 +1140,8 @@ class ArmedSeat {
843
1140
  const result = readCodexAnswers(
844
1141
  this.liveState.sessionFile,
845
1142
  this.liveState.offset,
846
- this.liveState.captureSinceMs
1143
+ this.liveState.captureSinceMs,
1144
+ { flowMode: this.flowMode === "on" }
847
1145
  );
848
1146
  this.liveState.offset = result.nextOffset;
849
1147
  answers.push(...result.answers);
@@ -851,7 +1149,8 @@ class ArmedSeat {
851
1149
  const result = readClaudeAnswers(
852
1150
  this.liveState.sessionFile,
853
1151
  this.liveState.offset,
854
- this.liveState.captureSinceMs
1152
+ this.liveState.captureSinceMs,
1153
+ { flowMode: this.flowMode === "on" }
855
1154
  );
856
1155
  this.liveState.offset = result.nextOffset;
857
1156
  answers.push(...result.answers);
@@ -859,7 +1158,8 @@ class ArmedSeat {
859
1158
  const result = readGeminiAnswers(
860
1159
  this.liveState.sessionFile,
861
1160
  this.liveState.lastMessageId,
862
- this.liveState.captureSinceMs
1161
+ this.liveState.captureSinceMs,
1162
+ { flowMode: this.flowMode === "on" }
863
1163
  );
864
1164
  this.liveState.lastMessageId = result.lastMessageId;
865
1165
  this.liveState.offset = result.fileSize;
@@ -895,19 +1195,39 @@ class ArmedSeat {
895
1195
  return;
896
1196
  }
897
1197
 
1198
+ const answerKey = buildAnswerKey(entry, payload);
1199
+ if (this.hasRecentEmittedAnswer(answerKey)) {
1200
+ this.log(`[${this.seatId}] suppressed duplicate final answer: ${previewText(payload)}`);
1201
+ return;
1202
+ }
1203
+
898
1204
  const mirroredInbound = this.takeMirroredInboundRelay(payload);
899
1205
  if (mirroredInbound) {
900
1206
  this.log(`[${this.seatId}] suppressed mirrored relay: ${previewText(payload)}`);
901
1207
  return;
902
1208
  }
903
1209
 
1210
+ const pendingInboundContext = this.getPendingInboundContext();
1211
+ if (this.flowMode !== "on" && pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1212
+ this.log(`[${this.seatId}] suppressed relay loop: ${previewText(payload)}`);
1213
+ return;
1214
+ }
1215
+
1216
+ if (pendingInboundContext?.relayUsed) {
1217
+ this.log(`[${this.seatId}] suppressed extra queued relay output: ${previewText(payload)}`);
1218
+ return;
1219
+ }
1220
+
1221
+ const entryId = entry.id || createId(12);
904
1222
  const signedEntry = {
905
- id: entry.id || createId(12),
1223
+ id: entryId,
906
1224
  type: "answer",
907
1225
  seatId: this.seatId,
908
1226
  origin: entry.origin || "unknown",
909
1227
  text: payload,
910
1228
  createdAt: entry.createdAt || new Date().toISOString(),
1229
+ chainId: pendingInboundContext?.chainId || entry.chainId || entryId,
1230
+ hop: pendingInboundContext ? pendingInboundContext.hop + 1 : 0,
911
1231
  challenge: this.trustState.challenge,
912
1232
  publicKey: this.identity.publicKey,
913
1233
  };
@@ -916,6 +1236,10 @@ class ArmedSeat {
916
1236
  this.identity.privateKey
917
1237
  );
918
1238
  appendJsonl(this.paths.eventsPath, signedEntry);
1239
+ this.rememberEmittedAnswer(answerKey);
1240
+ if (pendingInboundContext) {
1241
+ pendingInboundContext.relayUsed = true;
1242
+ }
919
1243
 
920
1244
  this.log(`[${this.seatId}] ${previewText(payload)}`);
921
1245
  }
@@ -946,6 +1270,7 @@ class ArmedSeat {
946
1270
  this.writeStatus({
947
1271
  state: live.state,
948
1272
  agent: live.agent,
1273
+ flowMode: this.flowMode,
949
1274
  cwd: live.cwd,
950
1275
  log: live.log,
951
1276
  lastAnswerAt: live.lastAnswerAt,
@@ -962,7 +1287,8 @@ class ArmedSeat {
962
1287
  this.installResizeHandler();
963
1288
 
964
1289
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
965
- 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}.`);
966
1292
  if (this.seatId === 1) {
967
1293
  this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
968
1294
  } else {
@@ -1028,6 +1354,17 @@ function previewText(text, maxLength = 88) {
1028
1354
  return `${compact.slice(0, maxLength - 3)}...`;
1029
1355
  }
1030
1356
 
1357
+ function buildAnswerKey(entry, payload) {
1358
+ const origin = String(entry.origin || "unknown").trim() || "unknown";
1359
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
1360
+ if (id) {
1361
+ return `${origin}:${id}`;
1362
+ }
1363
+
1364
+ const createdAt = typeof entry.createdAt === "string" ? entry.createdAt : "";
1365
+ return `${origin}:${createdAt}:${hashText(payload)}`;
1366
+ }
1367
+
1031
1368
  function buildSeatReport(sessionName, seatId) {
1032
1369
  const paths = getSeatPaths(sessionName, seatId);
1033
1370
  const daemon = readJson(paths.daemonPath, null);
@@ -1051,6 +1388,7 @@ function buildSeatReport(sessionName, seatId) {
1051
1388
  return {
1052
1389
  seatId,
1053
1390
  state: wrapperLive ? status?.state || "running" : "orphaned_child",
1391
+ flowMode: status?.flowMode || meta?.flowMode || "off",
1054
1392
  wrapperPid,
1055
1393
  childPid,
1056
1394
  wrapperLive,
@@ -1136,6 +1474,7 @@ function stopAllSessions() {
1136
1474
 
1137
1475
  module.exports = {
1138
1476
  ArmedSeat,
1477
+ buildChildEnv,
1139
1478
  getStatusReport,
1140
1479
  resolveSessionName,
1141
1480
  stopAllSessions,
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.",