muuuuse 2.2.5 → 2.3.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,6 +6,7 @@ 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
+ - additional isolated pairs work the same way: `3/4`, `5/6`, `7/8`, ...
9
10
  - choose per-seat relay mode with `flow on` or `flow off`
10
11
  - watch Codex, Claude, or Gemini for local assistant output
11
12
  - inject that output into the other armed terminal
@@ -16,8 +17,15 @@ The whole surface is:
16
17
  ```bash
17
18
  muuuuse 1
18
19
  muuuuse 1 flow on
20
+ muuuuse 1 flow off
21
+ muuuuse 1 flow off continue 5
19
22
  muuuuse 2
20
23
  muuuuse 2 flow off
24
+ muuuuse 2 flow on continue 3
25
+ muuuuse 3
26
+ muuuuse 3 flow on
27
+ muuuuse 4
28
+ muuuuse 4 flow off continue 1
21
29
  muuuuse status
22
30
  muuuuse stop
23
31
  ```
@@ -36,9 +44,11 @@ Terminal 2:
36
44
  muuuuse 2 flow off
37
45
  ```
38
46
 
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.
47
+ Now both shells are armed. `muuuuse 1` generates the session key, `muuuuse 2` signs it, and only that signed pair relays. Every odd/even adjacent pair works the same way in parallel: `3/4`, `5/6`, `7/8`, and so on. Use those shells normally.
40
48
 
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.
49
+ `flow on` means that seat relays commentary and final answers. `flow off` means that seat relays and accepts final answers only. Mixed calibration is allowed per seat.
50
+
51
+ `continue <seat>` forwards that seat's relayed output into another armed seat without changing the signed odd/even pair law. This lets you build local loops like `1 -> 2 -> 3 -> 4 -> 1` while every adjacent pair still keeps its own session keypair.
42
52
 
43
53
  If you want Codex in one and Gemini in the other, start them inside the armed shells:
44
54
 
@@ -69,6 +79,7 @@ muuuuse stop
69
79
  - no tmux
70
80
  - state lives under `~/.muuuuse`
71
81
  - only the signed armed pair can exchange relay events
82
+ - `continue <seat>` is a separate local forwarding lane and can target any armed seat number
72
83
  - supported relay detection is built for Codex, Claude, and Gemini
73
84
  - `codeman` remains the larger transport/control layer; `muuuuse` stays local and minimal
74
85
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muuuuse",
3
- "version": "2.2.5",
4
- "description": "🔌Muuuuse arms two regular terminals and relays assistant output between them.",
3
+ "version": "2.3.1",
4
+ "description": "🔌Muuuuse arms regular terminals in isolated pairs and can continue relay output into any other armed seat.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
7
  "muuuuse": "bin/muuse.js"
package/src/agents.js CHANGED
@@ -481,6 +481,7 @@ function parseCodexAssistantLine(line, options = {}) {
481
481
  return {
482
482
  id: entry.payload.id || hashText(line),
483
483
  text,
484
+ phase: phase === "commentary" ? "commentary" : "final_answer",
484
485
  timestamp: entry.timestamp || entry.payload.timestamp || new Date().toISOString(),
485
486
  };
486
487
  } catch {
@@ -597,6 +598,7 @@ function parseClaudeAssistantLine(line, options = {}) {
597
598
  return {
598
599
  id: entry.uuid || entry.message.id || hashText(line),
599
600
  text,
601
+ phase: flowMode && entry.message?.stop_reason !== "end_turn" ? "commentary" : "final_answer",
600
602
  timestamp: entry.timestamp || new Date().toISOString(),
601
603
  };
602
604
  } catch {
@@ -672,6 +674,7 @@ function readGeminiAnswers(filePath, lastMessageId = null, sinceMs = null) {
672
674
  const answers = finalMessages.slice(startIndex).map((message) => ({
673
675
  id: message.id || hashText(JSON.stringify(message)),
674
676
  text: sanitizeRelayText(message.content),
677
+ phase: "final_answer",
675
678
  timestamp: message.timestamp || entry.lastUpdated || new Date().toISOString(),
676
679
  }));
677
680
 
package/src/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- const { BRAND, usage } = require("./util");
1
+ const { BRAND, normalizeSeatId, usage } = require("./util");
2
2
  const { ArmedSeat, getStatusReport, stopAllSessions } = require("./runtime");
3
3
 
4
4
  async function main(argv = process.argv.slice(2)) {
@@ -54,12 +54,14 @@ async function main(argv = process.argv.slice(2)) {
54
54
  return;
55
55
  }
56
56
 
57
- if (command === "1" || command === "2") {
58
- const flowMode = parseSeatFlowMode(command, argv.slice(1));
57
+ const seatId = normalizeSeatId(command);
58
+ if (seatId) {
59
+ const { flowMode, continueSeatId } = parseSeatOptions(command, argv.slice(1));
59
60
  const seat = new ArmedSeat({
60
61
  cwd: process.cwd(),
62
+ continueSeatId,
61
63
  flowMode,
62
- seatId: Number(command),
64
+ seatId,
63
65
  });
64
66
  const code = await seat.run();
65
67
  process.exit(code);
@@ -81,6 +83,9 @@ function renderSeatStatus(seat) {
81
83
  if (seat.partnerLive) {
82
84
  bits.push("peer live");
83
85
  }
86
+ if (seat.continueSeatId) {
87
+ bits.push(`continue ${seat.continueSeatId}`);
88
+ }
84
89
  if (seat.trust) {
85
90
  bits.push(`trust ${seat.trust}`);
86
91
  }
@@ -98,23 +103,57 @@ function renderSeatStatus(seat) {
98
103
  return output;
99
104
  }
100
105
 
101
- function parseSeatFlowMode(command, args) {
102
- if (args.length === 0) {
103
- return "off";
104
- }
106
+ function parseSeatOptions(command, args) {
107
+ let flowMode = "off";
108
+ let continueSeatId = null;
105
109
 
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
+ for (let index = 0; index < args.length;) {
111
+ const token = String(args[index] || "").trim().toLowerCase();
112
+
113
+ if (token === "flow") {
114
+ const flowToken = String(args[index + 1] || "").trim().toLowerCase();
115
+ if (flowToken === "on" || flowToken === "off") {
116
+ flowMode = flowToken;
117
+ index += 2;
118
+ continue;
119
+ }
120
+ break;
121
+ }
122
+
123
+ if (token === "continue") {
124
+ const targetSeatId = normalizeSeatId(args[index + 1]);
125
+ if (targetSeatId) {
126
+ continueSeatId = targetSeatId;
127
+ index += 2;
128
+ continue;
129
+ }
130
+ break;
110
131
  }
132
+
133
+ break;
134
+ }
135
+
136
+ if (args.length === 0 || (flowMode || continueSeatId !== null) && consumedAllArgs(args, flowMode, continueSeatId)) {
137
+ return { flowMode, continueSeatId };
111
138
  }
112
139
 
113
140
  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.`
141
+ `\`muuuuse ${command}\` accepts no extra arguments, \`flow on\` / \`flow off\`, optional \`continue <seat>\`, or both in sequence. Run it directly in the terminal you want to arm.`
115
142
  );
116
143
  }
117
144
 
145
+ function consumedAllArgs(args, flowMode, continueSeatId) {
146
+ const expected = [];
147
+ if (flowMode !== "off" || args.includes("flow")) {
148
+ expected.push("flow", flowMode);
149
+ }
150
+ if (continueSeatId !== null || args.includes("continue")) {
151
+ expected.push("continue", String(continueSeatId));
152
+ }
153
+ return expected.length === args.length &&
154
+ expected.every((value, index) => String(args[index]).trim().toLowerCase() === String(value).trim().toLowerCase());
155
+ }
156
+
118
157
  module.exports = {
119
158
  main,
120
159
  };
package/src/runtime.js CHANGED
@@ -20,12 +20,17 @@ const {
20
20
  ensureDir,
21
21
  getDefaultSessionName,
22
22
  getFileSize,
23
+ getPartnerSeatId,
23
24
  getSeatPaths,
24
25
  getSessionPaths,
26
+ getStateRoot,
25
27
  hashText,
28
+ isAnchorSeat,
26
29
  isPidAlive,
30
+ listSeatIds,
27
31
  loadOrCreateSeatIdentity,
28
32
  listSessionNames,
33
+ normalizeSeatId,
29
34
  readAppendedText,
30
35
  readJson,
31
36
  sanitizeRelayText,
@@ -55,6 +60,11 @@ function normalizeFlowMode(flowMode) {
55
60
  return String(flowMode || "").trim().toLowerCase() === "on" ? "on" : "off";
56
61
  }
57
62
 
63
+ function normalizeContinueSeatId(value) {
64
+ const seatId = normalizeSeatId(value);
65
+ return seatId || null;
66
+ }
67
+
58
68
  function resolveShell() {
59
69
  const shell = String(process.env.SHELL || "").trim();
60
70
  return shell || "/bin/bash";
@@ -132,34 +142,40 @@ function sleepSync(ms) {
132
142
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
133
143
  }
134
144
 
135
- function findJoinableSessionName(currentPath = process.cwd()) {
145
+ function findJoinableSessionName(currentPath = process.cwd(), seatId = 2) {
146
+ const normalizedSeatId = normalizeSeatId(seatId);
147
+ const anchorSeatId = getPartnerSeatId(normalizedSeatId);
148
+ if (!normalizedSeatId || !anchorSeatId || isAnchorSeat(normalizedSeatId)) {
149
+ return null;
150
+ }
151
+
136
152
  const candidates = listSessionNames()
137
153
  .map((sessionName) => {
138
154
  const sessionPaths = getSessionPaths(sessionName);
139
155
  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);
156
+ const anchorPaths = getSeatPaths(sessionName, anchorSeatId);
157
+ const seatPaths = getSeatPaths(sessionName, normalizedSeatId);
158
+ const anchorMeta = readJson(anchorPaths.metaPath, null);
159
+ const anchorStatus = readJson(anchorPaths.statusPath, null);
160
+ const seatMeta = readJson(seatPaths.metaPath, null);
161
+ const seatStatus = readJson(seatPaths.statusPath, null);
146
162
  const stopRequest = readJson(sessionPaths.stopPath, null);
147
163
 
148
- const cwd = controller?.cwd || seat1Status?.cwd || seat1Meta?.cwd || seat2Status?.cwd || seat2Meta?.cwd || null;
164
+ const cwd = controller?.cwd || anchorStatus?.cwd || anchorMeta?.cwd || seatStatus?.cwd || seatMeta?.cwd || null;
149
165
  if (!matchesWorkingPath(cwd, currentPath)) {
150
166
  return null;
151
167
  }
152
168
 
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);
169
+ const anchorWrapperPid = anchorStatus?.pid || anchorMeta?.pid || null;
170
+ const anchorChildPid = anchorStatus?.childPid || anchorMeta?.childPid || null;
171
+ const seatWrapperPid = seatStatus?.pid || seatMeta?.pid || null;
172
+ const seatChildPid = seatStatus?.childPid || seatMeta?.childPid || null;
173
+ const anchorLive = isPidAlive(anchorWrapperPid) || isPidAlive(anchorChildPid);
174
+ const seatLive = isPidAlive(seatWrapperPid) || isPidAlive(seatChildPid);
159
175
  const stopRequestedAtMs = Date.parse(stopRequest?.requestedAt || "");
160
- const createdAtMs = Date.parse(controller?.createdAt || seat1Meta?.startedAt || seat1Status?.updatedAt || "");
176
+ const createdAtMs = Date.parse(controller?.createdAt || anchorMeta?.startedAt || anchorStatus?.updatedAt || "");
161
177
 
162
- if (!seat1Live || seat2Live) {
178
+ if (!anchorLive || seatLive) {
163
179
  return null;
164
180
  }
165
181
 
@@ -178,10 +194,10 @@ function findJoinableSessionName(currentPath = process.cwd()) {
178
194
  return candidates[0]?.sessionName || null;
179
195
  }
180
196
 
181
- function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEAT_JOIN_WAIT_MS) {
197
+ function waitForJoinableSessionName(currentPath = process.cwd(), seatId = 2, timeoutMs = SEAT_JOIN_WAIT_MS) {
182
198
  const deadline = Date.now() + timeoutMs;
183
199
  while (Date.now() <= deadline) {
184
- const sessionName = findJoinableSessionName(currentPath);
200
+ const sessionName = findJoinableSessionName(currentPath, seatId);
185
201
  if (sessionName) {
186
202
  return sessionName;
187
203
  }
@@ -192,15 +208,11 @@ function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEA
192
208
  }
193
209
 
194
210
  function resolveSessionName(currentPath = process.cwd(), seatId = 1) {
195
- if (seatId === 1) {
211
+ if (isAnchorSeat(seatId)) {
196
212
  return createSessionName(currentPath);
197
213
  }
198
214
 
199
- if (seatId === 2) {
200
- return waitForJoinableSessionName(currentPath);
201
- }
202
-
203
- return createSessionName(currentPath);
215
+ return waitForJoinableSessionName(currentPath, seatId);
204
216
  }
205
217
 
206
218
  function parseAnswerEntries(text) {
@@ -218,6 +230,26 @@ function parseAnswerEntries(text) {
218
230
  .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
219
231
  }
220
232
 
233
+ function parseContinueEntries(text, targetSeatId) {
234
+ return String(text || "")
235
+ .split("\n")
236
+ .map((line) => line.trim())
237
+ .filter(Boolean)
238
+ .map((line) => {
239
+ try {
240
+ return JSON.parse(line);
241
+ } catch {
242
+ return null;
243
+ }
244
+ })
245
+ .filter((entry) => (
246
+ entry &&
247
+ entry.type === "continue" &&
248
+ typeof entry.text === "string" &&
249
+ normalizeSeatId(entry.targetSeatId) === targetSeatId
250
+ ));
251
+ }
252
+
221
253
  function readSessionHeaderText(filePath, maxBytes = 16384) {
222
254
  try {
223
255
  const fd = fs.openSync(filePath, "r");
@@ -412,11 +444,52 @@ function buildAnswerSignaturePayload(sessionName, challenge, entry) {
412
444
  id: entry.id,
413
445
  seatId: entry.seatId,
414
446
  origin: entry.origin,
447
+ phase: entry.phase || "final_answer",
415
448
  createdAt: entry.createdAt,
416
449
  text: entry.text,
417
450
  });
418
451
  }
419
452
 
453
+ function buildContinuationEntry(sourceSessionName, targetSeatId, entry) {
454
+ return {
455
+ id: createId(12),
456
+ type: "continue",
457
+ sourceSessionName,
458
+ sourceSeatId: entry.seatId,
459
+ targetSeatId,
460
+ origin: entry.origin || "unknown",
461
+ phase: entry.phase || "final_answer",
462
+ text: entry.text,
463
+ createdAt: entry.createdAt || new Date().toISOString(),
464
+ chainId: entry.chainId,
465
+ hop: entry.hop,
466
+ sourceAnswerId: entry.id,
467
+ publicKey: entry.publicKey || null,
468
+ signature: entry.signature || null,
469
+ };
470
+ }
471
+
472
+ function getRelayPhase(entry) {
473
+ const phase = String(entry?.phase || "").trim().toLowerCase();
474
+ return phase === "commentary" ? "commentary" : "final_answer";
475
+ }
476
+
477
+ function shouldAcceptInboundEntry(flowMode, entry) {
478
+ return flowMode === "on" || getRelayPhase(entry) === "final_answer";
479
+ }
480
+
481
+ function getSeatDirIfExists(sessionName, seatId) {
482
+ const dir = path.join(getStateRoot(), "sessions", sessionName, `seat-${seatId}`);
483
+ try {
484
+ if (fs.statSync(dir).isDirectory()) {
485
+ return dir;
486
+ }
487
+ } catch {
488
+ return null;
489
+ }
490
+ return null;
491
+ }
492
+
420
493
  function readSeatChallenge(paths, sessionName) {
421
494
  const record = readJson(paths.challengePath, null);
422
495
  if (
@@ -511,16 +584,24 @@ async function sendTextAndEnter(child, text, shouldAbort = () => false) {
511
584
  class ArmedSeat {
512
585
  constructor(options) {
513
586
  this.seatId = options.seatId;
514
- this.partnerSeatId = options.seatId === 1 ? 2 : 1;
587
+ this.partnerSeatId = getPartnerSeatId(options.seatId);
588
+ this.anchorSeatId = isAnchorSeat(options.seatId) ? options.seatId : this.partnerSeatId;
515
589
  this.flowMode = normalizeFlowMode(options.flowMode);
590
+ this.continueSeatId = normalizeContinueSeatId(options.continueSeatId);
516
591
  this.cwd = normalizeWorkingPath(options.cwd);
592
+ if (this.continueSeatId === this.seatId) {
593
+ throw new Error(`\`muuuuse ${this.seatId}\` cannot continue to itself.`);
594
+ }
517
595
  this.sessionName = resolveSessionName(this.cwd, this.seatId);
518
596
  if (!this.sessionName) {
519
- throw new Error("No armed `muuuuse 1` seat is waiting in this cwd. Run `muuuuse 1` first.");
597
+ throw new Error(
598
+ `No armed \`muuuuse ${this.partnerSeatId}\` seat is waiting in this cwd. Run \`muuuuse ${this.partnerSeatId}\` first.`
599
+ );
520
600
  }
521
601
  this.sessionPaths = getSessionPaths(this.sessionName);
522
602
  this.paths = getSeatPaths(this.sessionName, this.seatId);
523
603
  this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
604
+ this.continueOffset = getFileSize(this.paths.continuePath);
524
605
  this.partnerOffset = getFileSize(this.partnerPaths.eventsPath);
525
606
 
526
607
  this.child = null;
@@ -542,7 +623,7 @@ class ArmedSeat {
542
623
  this.trustState = {
543
624
  challenge: null,
544
625
  peerPublicKey: null,
545
- phase: this.seatId === 1 ? "waiting_for_peer_signature" : "waiting_for_seat1_key",
626
+ phase: isAnchorSeat(this.seatId) ? "waiting_for_peer_signature" : "waiting_for_anchor_key",
546
627
  pairedAt: null,
547
628
  };
548
629
  this.liveState = {
@@ -565,9 +646,11 @@ class ArmedSeat {
565
646
  cwd: this.cwd,
566
647
  createdAt: current.createdAt || this.startedAt,
567
648
  updatedAt: new Date().toISOString(),
568
- seat1Pid: this.seatId === 1 ? process.pid : current.seat1Pid || null,
569
- seat2Pid: this.seatId === 2 ? process.pid : current.seat2Pid || null,
570
- pid: this.seatId === 1 ? process.pid : current.pid || null,
649
+ anchorSeatId: this.anchorSeatId,
650
+ partnerSeatId: this.partnerSeatId,
651
+ anchorSeatPid: this.seatId === this.anchorSeatId ? process.pid : current.anchorSeatPid || null,
652
+ partnerSeatPid: this.seatId === this.partnerSeatId ? process.pid : current.partnerSeatPid || null,
653
+ pid: this.seatId === this.anchorSeatId ? process.pid : current.pid || null,
571
654
  ...extra,
572
655
  });
573
656
  }
@@ -582,6 +665,7 @@ class ArmedSeat {
582
665
  partnerSeatId: this.partnerSeatId,
583
666
  sessionName: this.sessionName,
584
667
  flowMode: this.flowMode,
668
+ continueSeatId: this.continueSeatId,
585
669
  cwd: this.cwd,
586
670
  pid: process.pid,
587
671
  childPid: this.childPid,
@@ -597,6 +681,7 @@ class ArmedSeat {
597
681
  partnerSeatId: this.partnerSeatId,
598
682
  sessionName: this.sessionName,
599
683
  flowMode: this.flowMode,
684
+ continueSeatId: this.continueSeatId,
600
685
  cwd: this.cwd,
601
686
  pid: process.pid,
602
687
  childPid: this.childPid,
@@ -609,7 +694,7 @@ class ArmedSeat {
609
694
  initializeTrustMaterial() {
610
695
  this.identity = loadOrCreateSeatIdentity(this.paths);
611
696
 
612
- if (this.seatId !== 1) {
697
+ if (!isAnchorSeat(this.seatId)) {
613
698
  return;
614
699
  }
615
700
 
@@ -632,7 +717,7 @@ class ArmedSeat {
632
717
  this.initializeTrustMaterial();
633
718
  }
634
719
 
635
- if (this.seatId === 1) {
720
+ if (isAnchorSeat(this.seatId)) {
636
721
  this.syncSeatOneTrust();
637
722
  return;
638
723
  }
@@ -713,7 +798,7 @@ class ArmedSeat {
713
798
  this.trustState = {
714
799
  challenge: null,
715
800
  peerPublicKey: null,
716
- phase: "waiting_for_seat1_key",
801
+ phase: "waiting_for_anchor_key",
717
802
  pairedAt: null,
718
803
  };
719
804
  return;
@@ -941,6 +1026,44 @@ class ArmedSeat {
941
1026
  return Number.isFinite(requestedAtMs) && requestedAtMs > this.startedAtMs;
942
1027
  }
943
1028
 
1029
+ findContinuationTarget() {
1030
+ if (!this.continueSeatId) {
1031
+ return null;
1032
+ }
1033
+
1034
+ const candidates = listSessionNames()
1035
+ .map((sessionName) => {
1036
+ if (!getSeatDirIfExists(sessionName, this.continueSeatId)) {
1037
+ return null;
1038
+ }
1039
+
1040
+ const seat = buildSeatReport(sessionName, this.continueSeatId);
1041
+ if (!seat || !matchesWorkingPath(seat.cwd, this.cwd)) {
1042
+ return null;
1043
+ }
1044
+
1045
+ const updatedAtMs = Date.parse(seat.updatedAt || seat.startedAt || "");
1046
+ return {
1047
+ seat,
1048
+ sessionName,
1049
+ updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : 0,
1050
+ };
1051
+ })
1052
+ .filter((entry) => entry !== null)
1053
+ .sort((left, right) => right.updatedAtMs - left.updatedAtMs);
1054
+
1055
+ if (candidates.length === 0) {
1056
+ return null;
1057
+ }
1058
+
1059
+ const target = candidates[0];
1060
+ return {
1061
+ seatId: target.seat.seatId,
1062
+ sessionName: target.sessionName,
1063
+ paths: getSeatPaths(target.sessionName, target.seat.seatId),
1064
+ };
1065
+ }
1066
+
944
1067
  async pullPartnerEvents() {
945
1068
  const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
946
1069
  this.partnerOffset = nextOffset;
@@ -955,6 +1078,10 @@ class ArmedSeat {
955
1078
  return;
956
1079
  }
957
1080
 
1081
+ if (!shouldAcceptInboundEntry(this.flowMode, entry)) {
1082
+ continue;
1083
+ }
1084
+
958
1085
  const payload = sanitizeRelayText(entry.text);
959
1086
  const signaturePayload = buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, {
960
1087
  chainId: entry.chainId || entry.id,
@@ -962,6 +1089,7 @@ class ArmedSeat {
962
1089
  id: entry.id,
963
1090
  seatId: entry.seatId,
964
1091
  origin: entry.origin || "unknown",
1092
+ phase: getRelayPhase(entry),
965
1093
  createdAt: entry.createdAt,
966
1094
  text: payload,
967
1095
  });
@@ -1003,6 +1131,57 @@ class ArmedSeat {
1003
1131
  }
1004
1132
  }
1005
1133
 
1134
+ async pullContinuationEvents() {
1135
+ const { nextOffset, text } = readAppendedText(this.paths.continuePath, this.continueOffset);
1136
+ this.continueOffset = nextOffset;
1137
+ if (!text.trim() || !this.child || this.stopped) {
1138
+ return;
1139
+ }
1140
+
1141
+ const entries = parseContinueEntries(text, this.seatId);
1142
+ for (const entry of entries) {
1143
+ if (this.stopped || this.stopRequested()) {
1144
+ this.requestStop("stop_requested");
1145
+ return;
1146
+ }
1147
+
1148
+ if (!shouldAcceptInboundEntry(this.flowMode, entry)) {
1149
+ continue;
1150
+ }
1151
+
1152
+ const payload = sanitizeRelayText(entry.text);
1153
+ if (!payload) {
1154
+ continue;
1155
+ }
1156
+
1157
+ const delivered = await sendTextAndEnter(
1158
+ this.child,
1159
+ payload,
1160
+ () => this.stopped || this.stopRequested() || !this.child || Boolean(this.childExit)
1161
+ );
1162
+ if (!delivered) {
1163
+ this.requestStop("relay_aborted");
1164
+ return;
1165
+ }
1166
+
1167
+ if (this.stopped || this.stopRequested()) {
1168
+ this.requestStop("stop_requested");
1169
+ return;
1170
+ }
1171
+
1172
+ const deliveredAtMs = Date.now();
1173
+ this.pendingInboundContext = {
1174
+ chainId: entry.chainId || entry.sourceAnswerId || entry.id,
1175
+ deliveredAtMs,
1176
+ expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
1177
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
1178
+ };
1179
+ this.relayCount += 1;
1180
+ this.rememberInboundRelay(payload);
1181
+ this.log(`[${entry.sourceSeatId} => ${this.seatId}] ${previewText(payload)}`);
1182
+ }
1183
+ }
1184
+
1006
1185
  rememberInboundRelay(text) {
1007
1186
  const payload = sanitizeRelayText(text);
1008
1187
  if (!payload) {
@@ -1212,6 +1391,7 @@ class ArmedSeat {
1212
1391
  this.emitAnswer({
1213
1392
  id: answer.id || createId(12),
1214
1393
  origin: detectedAgent.type,
1394
+ phase: answer.phase || "final_answer",
1215
1395
  text: answer.text,
1216
1396
  createdAt: answer.timestamp || new Date().toISOString(),
1217
1397
  });
@@ -1257,6 +1437,7 @@ class ArmedSeat {
1257
1437
  type: "answer",
1258
1438
  seatId: this.seatId,
1259
1439
  origin: entry.origin || "unknown",
1440
+ phase: entry.phase || "final_answer",
1260
1441
  text: payload,
1261
1442
  createdAt: entry.createdAt || new Date().toISOString(),
1262
1443
  chainId: pendingInboundContext?.chainId || entry.chainId || entryId,
@@ -1269,11 +1450,28 @@ class ArmedSeat {
1269
1450
  this.identity.privateKey
1270
1451
  );
1271
1452
  appendJsonl(this.paths.eventsPath, signedEntry);
1453
+ this.forwardContinuation(signedEntry);
1272
1454
  this.rememberEmittedAnswer(answerKey);
1273
1455
 
1274
1456
  this.log(`[${this.seatId}] ${previewText(payload)}`);
1275
1457
  }
1276
1458
 
1459
+ forwardContinuation(signedEntry) {
1460
+ if (!this.continueSeatId) {
1461
+ return;
1462
+ }
1463
+
1464
+ const target = this.findContinuationTarget();
1465
+ if (!target) {
1466
+ this.log(`[${this.seatId}] continue ${this.continueSeatId} unavailable`);
1467
+ return;
1468
+ }
1469
+
1470
+ const continuationEntry = buildContinuationEntry(this.sessionName, target.seatId, signedEntry);
1471
+ appendJsonl(target.paths.continuePath, continuationEntry);
1472
+ this.log(`[${this.seatId} => ${target.seatId}] ${previewText(continuationEntry.text)}`);
1473
+ }
1474
+
1277
1475
  async tick() {
1278
1476
  if (this.stopRequested()) {
1279
1477
  this.writeStatus({
@@ -1287,6 +1485,7 @@ class ArmedSeat {
1287
1485
 
1288
1486
  this.syncTrustState();
1289
1487
  await this.pullPartnerEvents();
1488
+ await this.pullContinuationEvents();
1290
1489
  if (this.stopped || this.stopRequested()) {
1291
1490
  this.requestStop("stop_requested");
1292
1491
  return;
@@ -1319,10 +1518,13 @@ class ArmedSeat {
1319
1518
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
1320
1519
  this.log("Use this shell normally. Codex, Claude, and Gemini relay automatically from their local session logs.");
1321
1520
  this.log(`Seat ${this.seatId} relay mode is flow ${this.flowMode}.`);
1322
- if (this.seatId === 1) {
1323
- this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
1521
+ if (this.continueSeatId) {
1522
+ this.log(`Seat ${this.seatId} continues to seat ${this.continueSeatId}.`);
1523
+ }
1524
+ if (isAnchorSeat(this.seatId)) {
1525
+ this.log(`Seat ${this.seatId} generated the session key and is waiting for seat ${this.partnerSeatId} to sign it.`);
1324
1526
  } else {
1325
- this.log("Seat 2 will sign the session key from seat 1, then relay goes live.");
1527
+ this.log(`Seat ${this.seatId} will sign the session key from seat ${this.partnerSeatId}, then relay goes live.`);
1326
1528
  }
1327
1529
  this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
1328
1530
 
@@ -1420,6 +1622,7 @@ function buildSeatReport(sessionName, seatId) {
1420
1622
  seatId,
1421
1623
  state: wrapperLive ? status?.state || "running" : "orphaned_child",
1422
1624
  flowMode: status?.flowMode || meta?.flowMode || "off",
1625
+ continueSeatId: status?.continueSeatId || meta?.continueSeatId || null,
1423
1626
  wrapperPid,
1424
1627
  childPid,
1425
1628
  wrapperLive,
@@ -1443,7 +1646,7 @@ function getStatusReport() {
1443
1646
  const sessionPaths = getSessionPaths(sessionName);
1444
1647
  const controller = readJson(sessionPaths.controllerPath, null);
1445
1648
  const stopRequest = readJson(sessionPaths.stopPath, null);
1446
- const seats = [1, 2]
1649
+ const seats = listSeatIds(sessionName)
1447
1650
  .map((seatId) => buildSeatReport(sessionName, seatId))
1448
1651
  .filter((entry) => entry !== null);
1449
1652
 
package/src/util.js CHANGED
@@ -167,6 +167,42 @@ function getSessionPaths(sessionName) {
167
167
  };
168
168
  }
169
169
 
170
+ function normalizeSeatId(value) {
171
+ const seatId = Number.parseInt(String(value || "").trim(), 10);
172
+ if (!Number.isInteger(seatId) || seatId <= 0) {
173
+ return null;
174
+ }
175
+ return seatId;
176
+ }
177
+
178
+ function isAnchorSeat(seatId) {
179
+ return normalizeSeatId(seatId) % 2 === 1;
180
+ }
181
+
182
+ function getPartnerSeatId(seatId) {
183
+ const normalized = normalizeSeatId(seatId);
184
+ if (!normalized) {
185
+ return null;
186
+ }
187
+ return isAnchorSeat(normalized) ? normalized + 1 : normalized - 1;
188
+ }
189
+
190
+ function listSeatIds(sessionName) {
191
+ const sessionDir = getSessionDir(sessionName);
192
+ try {
193
+ return fs.readdirSync(sessionDir, { withFileTypes: true })
194
+ .filter((entry) => entry.isDirectory())
195
+ .map((entry) => {
196
+ const match = entry.name.match(/^seat-(\d+)$/);
197
+ return match ? Number.parseInt(match[1], 10) : null;
198
+ })
199
+ .filter((seatId) => Number.isInteger(seatId))
200
+ .sort((left, right) => left - right);
201
+ } catch {
202
+ return [];
203
+ }
204
+ }
205
+
170
206
  function getSeatDir(sessionName, seatId) {
171
207
  return ensureDir(path.join(getSessionDir(sessionName), `seat-${seatId}`));
172
208
  }
@@ -177,6 +213,7 @@ function getSeatPaths(sessionName, seatId) {
177
213
  ackPath: path.join(dir, "ack.json"),
178
214
  challengePath: path.join(dir, "challenge.json"),
179
215
  claimPath: path.join(dir, "claim.json"),
216
+ continuePath: path.join(dir, "continue.jsonl"),
180
217
  dir,
181
218
  daemonPath: path.join(dir, "daemon.json"),
182
219
  eventsPath: path.join(dir, "events.jsonl"),
@@ -245,26 +282,33 @@ function listSessionNames() {
245
282
 
246
283
  function usage() {
247
284
  return [
248
- `${BRAND} arms two regular terminals and relays assistant output between them.`,
285
+ `${BRAND} arms regular terminals in isolated odd/even pairs and relays assistant output between each pair.`,
249
286
  "",
250
287
  "Usage:",
251
288
  " muuuuse 1",
252
289
  " muuuuse 1 flow on",
253
290
  " muuuuse 1 flow off",
291
+ " muuuuse 1 flow on continue 3",
254
292
  " muuuuse 2",
255
293
  " muuuuse 2 flow on",
256
294
  " muuuuse 2 flow off",
295
+ " muuuuse 2 flow on continue 3",
296
+ " muuuuse 3",
297
+ " muuuuse 4",
298
+ " muuuuse 4 flow on continue 1",
257
299
  " muuuuse stop",
258
300
  " muuuuse status",
259
301
  "",
260
302
  "Flow:",
261
303
  " 1. Run `muuuuse 1` in terminal one.",
262
304
  " 2. Run `muuuuse 2` in terminal two.",
263
- " 3. Seat 1 generates the session key and seat 2 signs it automatically.",
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.",
305
+ " 3. The odd seat generates the session key and the matching even seat signs it automatically.",
306
+ " 4. Additional pairs work the same way: `3/4`, `5/6`, `7/8`...",
307
+ " 5. Optional: arm each seat with `flow on` or `flow off`.",
308
+ " 6. Optional: add `continue <seat>` to forward that seat's relayed output into another armed seat.",
309
+ " 7. Use those armed shells normally.",
310
+ " 8. `flow off` sends final answers only. `flow on` keeps assistant commentary bouncing.",
311
+ " 9. Run `muuuuse status` or `muuuuse stop` from any shell.",
268
312
  "",
269
313
  "Notes:",
270
314
  " - No tmux.",
@@ -282,13 +326,17 @@ module.exports = {
282
326
  ensureDir,
283
327
  getDefaultSessionName,
284
328
  getFileSize,
329
+ getPartnerSeatId,
285
330
  loadOrCreateSeatIdentity,
331
+ isAnchorSeat,
286
332
  getSeatPaths,
287
333
  getSessionPaths,
288
334
  getStateRoot,
289
335
  hashText,
290
336
  isPidAlive,
337
+ listSeatIds,
291
338
  listSessionNames,
339
+ normalizeSeatId,
292
340
  readAppendedText,
293
341
  readJson,
294
342
  sanitizeRelayText,