muuuuse 2.2.4 → 2.3.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,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,10 +44,12 @@ 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
49
  `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
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.
52
+
43
53
  If you want Codex in one and Gemini in the other, start them inside the armed shells:
44
54
 
45
55
  ```bash
@@ -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.4",
4
- "description": "🔌Muuuuse arms two regular terminals and relays assistant output between them.",
3
+ "version": "2.3.0",
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/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,
@@ -42,7 +47,6 @@ const PENDING_RELAY_CONTEXT_TTL_MS = 2 * 60 * 1000;
42
47
  const EMITTED_ANSWER_TTL_MS = 5 * 60 * 1000;
43
48
  const MAX_RECENT_INBOUND_RELAYS = 12;
44
49
  const MAX_RECENT_EMITTED_ANSWERS = 48;
45
- const MAX_RELAY_CHAIN_HOP = 1;
46
50
  const STOP_FORCE_KILL_MS = 1200;
47
51
  const SEAT_JOIN_WAIT_MS = 3000;
48
52
  const SEAT_JOIN_POLL_MS = 60;
@@ -56,6 +60,11 @@ function normalizeFlowMode(flowMode) {
56
60
  return String(flowMode || "").trim().toLowerCase() === "on" ? "on" : "off";
57
61
  }
58
62
 
63
+ function normalizeContinueSeatId(value) {
64
+ const seatId = normalizeSeatId(value);
65
+ return seatId || null;
66
+ }
67
+
59
68
  function resolveShell() {
60
69
  const shell = String(process.env.SHELL || "").trim();
61
70
  return shell || "/bin/bash";
@@ -133,34 +142,40 @@ function sleepSync(ms) {
133
142
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
134
143
  }
135
144
 
136
- 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
+
137
152
  const candidates = listSessionNames()
138
153
  .map((sessionName) => {
139
154
  const sessionPaths = getSessionPaths(sessionName);
140
155
  const controller = readJson(sessionPaths.controllerPath, null);
141
- const seat1Paths = getSeatPaths(sessionName, 1);
142
- const seat2Paths = getSeatPaths(sessionName, 2);
143
- const seat1Meta = readJson(seat1Paths.metaPath, null);
144
- const seat1Status = readJson(seat1Paths.statusPath, null);
145
- const seat2Meta = readJson(seat2Paths.metaPath, null);
146
- 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);
147
162
  const stopRequest = readJson(sessionPaths.stopPath, null);
148
163
 
149
- 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;
150
165
  if (!matchesWorkingPath(cwd, currentPath)) {
151
166
  return null;
152
167
  }
153
168
 
154
- const seat1WrapperPid = seat1Status?.pid || seat1Meta?.pid || null;
155
- const seat1ChildPid = seat1Status?.childPid || seat1Meta?.childPid || null;
156
- const seat2WrapperPid = seat2Status?.pid || seat2Meta?.pid || null;
157
- const seat2ChildPid = seat2Status?.childPid || seat2Meta?.childPid || null;
158
- const seat1Live = isPidAlive(seat1WrapperPid) || isPidAlive(seat1ChildPid);
159
- 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);
160
175
  const stopRequestedAtMs = Date.parse(stopRequest?.requestedAt || "");
161
- const createdAtMs = Date.parse(controller?.createdAt || seat1Meta?.startedAt || seat1Status?.updatedAt || "");
176
+ const createdAtMs = Date.parse(controller?.createdAt || anchorMeta?.startedAt || anchorStatus?.updatedAt || "");
162
177
 
163
- if (!seat1Live || seat2Live) {
178
+ if (!anchorLive || seatLive) {
164
179
  return null;
165
180
  }
166
181
 
@@ -179,10 +194,10 @@ function findJoinableSessionName(currentPath = process.cwd()) {
179
194
  return candidates[0]?.sessionName || null;
180
195
  }
181
196
 
182
- function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEAT_JOIN_WAIT_MS) {
197
+ function waitForJoinableSessionName(currentPath = process.cwd(), seatId = 2, timeoutMs = SEAT_JOIN_WAIT_MS) {
183
198
  const deadline = Date.now() + timeoutMs;
184
199
  while (Date.now() <= deadline) {
185
- const sessionName = findJoinableSessionName(currentPath);
200
+ const sessionName = findJoinableSessionName(currentPath, seatId);
186
201
  if (sessionName) {
187
202
  return sessionName;
188
203
  }
@@ -193,15 +208,11 @@ function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEA
193
208
  }
194
209
 
195
210
  function resolveSessionName(currentPath = process.cwd(), seatId = 1) {
196
- if (seatId === 1) {
211
+ if (isAnchorSeat(seatId)) {
197
212
  return createSessionName(currentPath);
198
213
  }
199
214
 
200
- if (seatId === 2) {
201
- return waitForJoinableSessionName(currentPath);
202
- }
203
-
204
- return createSessionName(currentPath);
215
+ return waitForJoinableSessionName(currentPath, seatId);
205
216
  }
206
217
 
207
218
  function parseAnswerEntries(text) {
@@ -219,6 +230,26 @@ function parseAnswerEntries(text) {
219
230
  .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
220
231
  }
221
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
+
222
253
  function readSessionHeaderText(filePath, maxBytes = 16384) {
223
254
  try {
224
255
  const fd = fs.openSync(filePath, "r");
@@ -418,6 +449,36 @@ function buildAnswerSignaturePayload(sessionName, challenge, entry) {
418
449
  });
419
450
  }
420
451
 
452
+ function buildContinuationEntry(sourceSessionName, targetSeatId, entry) {
453
+ return {
454
+ id: createId(12),
455
+ type: "continue",
456
+ sourceSessionName,
457
+ sourceSeatId: entry.seatId,
458
+ targetSeatId,
459
+ origin: entry.origin || "unknown",
460
+ text: entry.text,
461
+ createdAt: entry.createdAt || new Date().toISOString(),
462
+ chainId: entry.chainId,
463
+ hop: entry.hop,
464
+ sourceAnswerId: entry.id,
465
+ publicKey: entry.publicKey || null,
466
+ signature: entry.signature || null,
467
+ };
468
+ }
469
+
470
+ function getSeatDirIfExists(sessionName, seatId) {
471
+ const dir = path.join(getStateRoot(), "sessions", sessionName, `seat-${seatId}`);
472
+ try {
473
+ if (fs.statSync(dir).isDirectory()) {
474
+ return dir;
475
+ }
476
+ } catch {
477
+ return null;
478
+ }
479
+ return null;
480
+ }
481
+
421
482
  function readSeatChallenge(paths, sessionName) {
422
483
  const record = readJson(paths.challengePath, null);
423
484
  if (
@@ -512,16 +573,24 @@ async function sendTextAndEnter(child, text, shouldAbort = () => false) {
512
573
  class ArmedSeat {
513
574
  constructor(options) {
514
575
  this.seatId = options.seatId;
515
- this.partnerSeatId = options.seatId === 1 ? 2 : 1;
576
+ this.partnerSeatId = getPartnerSeatId(options.seatId);
577
+ this.anchorSeatId = isAnchorSeat(options.seatId) ? options.seatId : this.partnerSeatId;
516
578
  this.flowMode = normalizeFlowMode(options.flowMode);
579
+ this.continueSeatId = normalizeContinueSeatId(options.continueSeatId);
517
580
  this.cwd = normalizeWorkingPath(options.cwd);
581
+ if (this.continueSeatId === this.seatId) {
582
+ throw new Error(`\`muuuuse ${this.seatId}\` cannot continue to itself.`);
583
+ }
518
584
  this.sessionName = resolveSessionName(this.cwd, this.seatId);
519
585
  if (!this.sessionName) {
520
- throw new Error("No armed `muuuuse 1` seat is waiting in this cwd. Run `muuuuse 1` first.");
586
+ throw new Error(
587
+ `No armed \`muuuuse ${this.partnerSeatId}\` seat is waiting in this cwd. Run \`muuuuse ${this.partnerSeatId}\` first.`
588
+ );
521
589
  }
522
590
  this.sessionPaths = getSessionPaths(this.sessionName);
523
591
  this.paths = getSeatPaths(this.sessionName, this.seatId);
524
592
  this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
593
+ this.continueOffset = getFileSize(this.paths.continuePath);
525
594
  this.partnerOffset = getFileSize(this.partnerPaths.eventsPath);
526
595
 
527
596
  this.child = null;
@@ -543,7 +612,7 @@ class ArmedSeat {
543
612
  this.trustState = {
544
613
  challenge: null,
545
614
  peerPublicKey: null,
546
- phase: this.seatId === 1 ? "waiting_for_peer_signature" : "waiting_for_seat1_key",
615
+ phase: isAnchorSeat(this.seatId) ? "waiting_for_peer_signature" : "waiting_for_anchor_key",
547
616
  pairedAt: null,
548
617
  };
549
618
  this.liveState = {
@@ -566,9 +635,11 @@ class ArmedSeat {
566
635
  cwd: this.cwd,
567
636
  createdAt: current.createdAt || this.startedAt,
568
637
  updatedAt: new Date().toISOString(),
569
- seat1Pid: this.seatId === 1 ? process.pid : current.seat1Pid || null,
570
- seat2Pid: this.seatId === 2 ? process.pid : current.seat2Pid || null,
571
- pid: this.seatId === 1 ? process.pid : current.pid || null,
638
+ anchorSeatId: this.anchorSeatId,
639
+ partnerSeatId: this.partnerSeatId,
640
+ anchorSeatPid: this.seatId === this.anchorSeatId ? process.pid : current.anchorSeatPid || null,
641
+ partnerSeatPid: this.seatId === this.partnerSeatId ? process.pid : current.partnerSeatPid || null,
642
+ pid: this.seatId === this.anchorSeatId ? process.pid : current.pid || null,
572
643
  ...extra,
573
644
  });
574
645
  }
@@ -583,6 +654,7 @@ class ArmedSeat {
583
654
  partnerSeatId: this.partnerSeatId,
584
655
  sessionName: this.sessionName,
585
656
  flowMode: this.flowMode,
657
+ continueSeatId: this.continueSeatId,
586
658
  cwd: this.cwd,
587
659
  pid: process.pid,
588
660
  childPid: this.childPid,
@@ -598,6 +670,7 @@ class ArmedSeat {
598
670
  partnerSeatId: this.partnerSeatId,
599
671
  sessionName: this.sessionName,
600
672
  flowMode: this.flowMode,
673
+ continueSeatId: this.continueSeatId,
601
674
  cwd: this.cwd,
602
675
  pid: process.pid,
603
676
  childPid: this.childPid,
@@ -610,7 +683,7 @@ class ArmedSeat {
610
683
  initializeTrustMaterial() {
611
684
  this.identity = loadOrCreateSeatIdentity(this.paths);
612
685
 
613
- if (this.seatId !== 1) {
686
+ if (!isAnchorSeat(this.seatId)) {
614
687
  return;
615
688
  }
616
689
 
@@ -633,7 +706,7 @@ class ArmedSeat {
633
706
  this.initializeTrustMaterial();
634
707
  }
635
708
 
636
- if (this.seatId === 1) {
709
+ if (isAnchorSeat(this.seatId)) {
637
710
  this.syncSeatOneTrust();
638
711
  return;
639
712
  }
@@ -714,7 +787,7 @@ class ArmedSeat {
714
787
  this.trustState = {
715
788
  challenge: null,
716
789
  peerPublicKey: null,
717
- phase: "waiting_for_seat1_key",
790
+ phase: "waiting_for_anchor_key",
718
791
  pairedAt: null,
719
792
  };
720
793
  return;
@@ -932,12 +1005,6 @@ class ArmedSeat {
932
1005
  return Boolean(partner?.pid && isPidAlive(partner.pid));
933
1006
  }
934
1007
 
935
- getPartnerFlowMode() {
936
- const partnerStatus = readJson(this.partnerPaths.statusPath, null);
937
- const partnerMeta = readJson(this.partnerPaths.metaPath, null);
938
- return normalizeFlowMode(partnerStatus?.flowMode || partnerMeta?.flowMode || "off");
939
- }
940
-
941
1008
  stopRequested() {
942
1009
  const request = readJson(this.sessionPaths.stopPath, null);
943
1010
  if (!request?.requestedAt) {
@@ -948,6 +1015,44 @@ class ArmedSeat {
948
1015
  return Number.isFinite(requestedAtMs) && requestedAtMs > this.startedAtMs;
949
1016
  }
950
1017
 
1018
+ findContinuationTarget() {
1019
+ if (!this.continueSeatId) {
1020
+ return null;
1021
+ }
1022
+
1023
+ const candidates = listSessionNames()
1024
+ .map((sessionName) => {
1025
+ if (!getSeatDirIfExists(sessionName, this.continueSeatId)) {
1026
+ return null;
1027
+ }
1028
+
1029
+ const seat = buildSeatReport(sessionName, this.continueSeatId);
1030
+ if (!seat || !matchesWorkingPath(seat.cwd, this.cwd)) {
1031
+ return null;
1032
+ }
1033
+
1034
+ const updatedAtMs = Date.parse(seat.updatedAt || seat.startedAt || "");
1035
+ return {
1036
+ seat,
1037
+ sessionName,
1038
+ updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : 0,
1039
+ };
1040
+ })
1041
+ .filter((entry) => entry !== null)
1042
+ .sort((left, right) => right.updatedAtMs - left.updatedAtMs);
1043
+
1044
+ if (candidates.length === 0) {
1045
+ return null;
1046
+ }
1047
+
1048
+ const target = candidates[0];
1049
+ return {
1050
+ seatId: target.seat.seatId,
1051
+ sessionName: target.sessionName,
1052
+ paths: getSeatPaths(target.sessionName, target.seat.seatId),
1053
+ };
1054
+ }
1055
+
951
1056
  async pullPartnerEvents() {
952
1057
  const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
953
1058
  this.partnerOffset = nextOffset;
@@ -1003,7 +1108,6 @@ class ArmedSeat {
1003
1108
  deliveredAtMs,
1004
1109
  expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
1005
1110
  hop: Number.isInteger(entry.hop) ? entry.hop : 0,
1006
- relayUsed: false,
1007
1111
  };
1008
1112
  this.relayCount += 1;
1009
1113
  this.rememberInboundRelay(payload);
@@ -1011,6 +1115,53 @@ class ArmedSeat {
1011
1115
  }
1012
1116
  }
1013
1117
 
1118
+ async pullContinuationEvents() {
1119
+ const { nextOffset, text } = readAppendedText(this.paths.continuePath, this.continueOffset);
1120
+ this.continueOffset = nextOffset;
1121
+ if (!text.trim() || !this.child || this.stopped) {
1122
+ return;
1123
+ }
1124
+
1125
+ const entries = parseContinueEntries(text, this.seatId);
1126
+ for (const entry of entries) {
1127
+ if (this.stopped || this.stopRequested()) {
1128
+ this.requestStop("stop_requested");
1129
+ return;
1130
+ }
1131
+
1132
+ const payload = sanitizeRelayText(entry.text);
1133
+ if (!payload) {
1134
+ continue;
1135
+ }
1136
+
1137
+ const delivered = await sendTextAndEnter(
1138
+ this.child,
1139
+ payload,
1140
+ () => this.stopped || this.stopRequested() || !this.child || Boolean(this.childExit)
1141
+ );
1142
+ if (!delivered) {
1143
+ this.requestStop("relay_aborted");
1144
+ return;
1145
+ }
1146
+
1147
+ if (this.stopped || this.stopRequested()) {
1148
+ this.requestStop("stop_requested");
1149
+ return;
1150
+ }
1151
+
1152
+ const deliveredAtMs = Date.now();
1153
+ this.pendingInboundContext = {
1154
+ chainId: entry.chainId || entry.sourceAnswerId || entry.id,
1155
+ deliveredAtMs,
1156
+ expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
1157
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
1158
+ };
1159
+ this.relayCount += 1;
1160
+ this.rememberInboundRelay(payload);
1161
+ this.log(`[${entry.sourceSeatId} => ${this.seatId}] ${previewText(payload)}`);
1162
+ }
1163
+ }
1164
+
1014
1165
  rememberInboundRelay(text) {
1015
1166
  const payload = sanitizeRelayText(text);
1016
1167
  if (!payload) {
@@ -1258,21 +1409,6 @@ class ArmedSeat {
1258
1409
  }
1259
1410
 
1260
1411
  const pendingInboundContext = this.getPendingInboundContext();
1261
- const partnerFlowMode = this.getPartnerFlowMode();
1262
- if (
1263
- this.flowMode !== "on" &&
1264
- partnerFlowMode !== "on" &&
1265
- pendingInboundContext &&
1266
- pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP
1267
- ) {
1268
- this.log(`[${this.seatId}] suppressed relay loop: ${previewText(payload)}`);
1269
- return;
1270
- }
1271
-
1272
- if (pendingInboundContext?.relayUsed) {
1273
- this.log(`[${this.seatId}] suppressed extra queued relay output: ${previewText(payload)}`);
1274
- return;
1275
- }
1276
1412
 
1277
1413
  const entryId = entry.id || createId(12);
1278
1414
  const signedEntry = {
@@ -1292,14 +1428,28 @@ class ArmedSeat {
1292
1428
  this.identity.privateKey
1293
1429
  );
1294
1430
  appendJsonl(this.paths.eventsPath, signedEntry);
1431
+ this.forwardContinuation(signedEntry);
1295
1432
  this.rememberEmittedAnswer(answerKey);
1296
- if (pendingInboundContext) {
1297
- pendingInboundContext.relayUsed = true;
1298
- }
1299
1433
 
1300
1434
  this.log(`[${this.seatId}] ${previewText(payload)}`);
1301
1435
  }
1302
1436
 
1437
+ forwardContinuation(signedEntry) {
1438
+ if (!this.continueSeatId) {
1439
+ return;
1440
+ }
1441
+
1442
+ const target = this.findContinuationTarget();
1443
+ if (!target) {
1444
+ this.log(`[${this.seatId}] continue ${this.continueSeatId} unavailable`);
1445
+ return;
1446
+ }
1447
+
1448
+ const continuationEntry = buildContinuationEntry(this.sessionName, target.seatId, signedEntry);
1449
+ appendJsonl(target.paths.continuePath, continuationEntry);
1450
+ this.log(`[${this.seatId} => ${target.seatId}] ${previewText(continuationEntry.text)}`);
1451
+ }
1452
+
1303
1453
  async tick() {
1304
1454
  if (this.stopRequested()) {
1305
1455
  this.writeStatus({
@@ -1313,6 +1463,7 @@ class ArmedSeat {
1313
1463
 
1314
1464
  this.syncTrustState();
1315
1465
  await this.pullPartnerEvents();
1466
+ await this.pullContinuationEvents();
1316
1467
  if (this.stopped || this.stopRequested()) {
1317
1468
  this.requestStop("stop_requested");
1318
1469
  return;
@@ -1345,10 +1496,13 @@ class ArmedSeat {
1345
1496
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
1346
1497
  this.log("Use this shell normally. Codex, Claude, and Gemini relay automatically from their local session logs.");
1347
1498
  this.log(`Seat ${this.seatId} relay mode is flow ${this.flowMode}.`);
1348
- if (this.seatId === 1) {
1349
- this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
1499
+ if (this.continueSeatId) {
1500
+ this.log(`Seat ${this.seatId} continues to seat ${this.continueSeatId}.`);
1501
+ }
1502
+ if (isAnchorSeat(this.seatId)) {
1503
+ this.log(`Seat ${this.seatId} generated the session key and is waiting for seat ${this.partnerSeatId} to sign it.`);
1350
1504
  } else {
1351
- this.log("Seat 2 will sign the session key from seat 1, then relay goes live.");
1505
+ this.log(`Seat ${this.seatId} will sign the session key from seat ${this.partnerSeatId}, then relay goes live.`);
1352
1506
  }
1353
1507
  this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
1354
1508
 
@@ -1413,12 +1567,13 @@ function previewText(text, maxLength = 88) {
1413
1567
  function buildAnswerKey(entry, payload) {
1414
1568
  const origin = String(entry.origin || "unknown").trim() || "unknown";
1415
1569
  const id = typeof entry.id === "string" ? entry.id.trim() : "";
1570
+ const payloadHash = hashText(payload);
1416
1571
  if (id) {
1417
- return `${origin}:${id}`;
1572
+ return `${origin}:${id}:${payloadHash}`;
1418
1573
  }
1419
1574
 
1420
1575
  const createdAt = typeof entry.createdAt === "string" ? entry.createdAt : "";
1421
- return `${origin}:${createdAt}:${hashText(payload)}`;
1576
+ return `${origin}:${createdAt}:${payloadHash}`;
1422
1577
  }
1423
1578
 
1424
1579
  function buildSeatReport(sessionName, seatId) {
@@ -1445,6 +1600,7 @@ function buildSeatReport(sessionName, seatId) {
1445
1600
  seatId,
1446
1601
  state: wrapperLive ? status?.state || "running" : "orphaned_child",
1447
1602
  flowMode: status?.flowMode || meta?.flowMode || "off",
1603
+ continueSeatId: status?.continueSeatId || meta?.continueSeatId || null,
1448
1604
  wrapperPid,
1449
1605
  childPid,
1450
1606
  wrapperLive,
@@ -1468,7 +1624,7 @@ function getStatusReport() {
1468
1624
  const sessionPaths = getSessionPaths(sessionName);
1469
1625
  const controller = readJson(sessionPaths.controllerPath, null);
1470
1626
  const stopRequest = readJson(sessionPaths.stopPath, null);
1471
- const seats = [1, 2]
1627
+ const seats = listSeatIds(sessionName)
1472
1628
  .map((seatId) => buildSeatReport(sessionName, seatId))
1473
1629
  .filter((entry) => entry !== null);
1474
1630
 
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,