muuuuse 2.3.6 → 3.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
@@ -5,11 +5,10 @@
5
5
  It does one job:
6
6
  - arm terminal one with `muuuuse 1`
7
7
  - arm terminal two with `muuuuse 2`
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`, ...
8
+ - let each seat define signed relay links to any other armed seat
10
9
  - choose per-seat relay mode with `flow on` or `flow off`
11
10
  - watch Codex, Claude, or Gemini for local assistant output
12
- - inject that output into the other armed terminal
11
+ - inject that output into linked armed terminals
13
12
  - keep looping until you stop it
14
13
 
15
14
  The whole surface is:
@@ -19,9 +18,11 @@ muuuuse 1
19
18
  muuuuse 1 flow on
20
19
  muuuuse 1 flow off
21
20
  muuuuse 1 flow off continue 5
21
+ muuuuse 1 link 2 flow on 3 flow off 5 flow off
22
22
  muuuuse 2
23
23
  muuuuse 2 flow off
24
24
  muuuuse 2 flow on continue 3
25
+ muuuuse 2 link 1 flow off 3 flow on 4 flow on
25
26
  muuuuse 3
26
27
  muuuuse 3 flow on
27
28
  muuuuse 4
@@ -35,20 +36,20 @@ muuuuse stop
35
36
  Terminal 1:
36
37
 
37
38
  ```bash
38
- muuuuse 1 flow on
39
+ muuuuse 1 link 2 flow on
39
40
  ```
40
41
 
41
42
  Terminal 2:
42
43
 
43
44
  ```bash
44
- muuuuse 2 flow off
45
+ muuuuse 2 link 1 flow off link 3 flow on link 4 flow on
45
46
  ```
46
47
 
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.
48
+ Now both shells are armed in the same cwd and join the same relay graph. Every seat has its own Ed25519 keypair. Each forwarded relay is signed by the sending seat. A target seat only accepts inbound relays from seats it links back to, so the graph can be open-ended without becoming an all-to-all broadcast.
48
49
 
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
+ `link <seat> flow on` means that outbound edge sends commentary and final answers. `link <seat> flow off` means that outbound edge sends final answers only. This is sender-side routing, not receiver-side filtering.
50
51
 
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
+ `continue <seat>` is shorthand for one outbound link that uses the seat's default `flow on|off`. Explicit `link ... flow ...` edges are the full model and can be arranged into loops such as `1 -> 2 -> 3 -> 4 -> 1`.
52
53
 
53
54
  If you want Codex in one and Gemini in the other, start them inside the armed shells:
54
55
 
@@ -60,7 +61,7 @@ codex
60
61
  gemini
61
62
  ```
62
63
 
63
- `🔌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.
64
+ `🔌Muuuuse` tails the local session logs for supported CLIs, relays according to each outbound link's flow mode, types that output into the target seat, and then sends Enter as a separate keystroke.
64
65
 
65
66
  Check the live state from any terminal:
66
67
 
@@ -77,8 +78,9 @@ muuuuse stop
77
78
  ## Notes
78
79
 
79
80
  - state lives under `~/.muuuuse`
80
- - only the signed armed pair can exchange relay events
81
- - `continue <seat>` is a separate local forwarding lane and can target any armed seat number
81
+ - all armed seats in the same cwd share one relay session graph
82
+ - only signed relays from reciprocally linked seats are accepted
83
+ - `continue <seat>` is a convenience alias for a single signed outbound link
82
84
  - supported relay detection is built for Codex, Claude, and Gemini
83
85
 
84
86
  ## Install
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muuuuse",
3
- "version": "2.3.6",
4
- "description": "🔌Muuuuse arms regular terminals in isolated pairs and can continue relay output into any other armed seat.",
3
+ "version": "3.3.1",
4
+ "description": "🔌Muuuuse arms regular terminals and relays assistant output across signed terminal links.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
7
  "muuuuse": "bin/muuse.js"
package/src/cli.js CHANGED
@@ -56,10 +56,11 @@ async function main(argv = process.argv.slice(2)) {
56
56
 
57
57
  const seatId = normalizeSeatId(command);
58
58
  if (seatId) {
59
- const { flowMode, continueSeatId } = parseSeatOptions(command, argv.slice(1));
59
+ const { flowMode, continueSeatId, continueTargets } = parseSeatOptions(command, argv.slice(1));
60
60
  const seat = new ArmedSeat({
61
61
  cwd: process.cwd(),
62
62
  continueSeatId,
63
+ continueTargets,
63
64
  flowMode,
64
65
  seatId,
65
66
  });
@@ -80,14 +81,11 @@ function renderSeatStatus(seat) {
80
81
  `child ${seat.childPid || "-"}`,
81
82
  ];
82
83
 
83
- if (seat.partnerLive) {
84
- bits.push("peer live");
85
- }
86
84
  if (seat.continueSeatId) {
87
85
  bits.push(`continue ${seat.continueSeatId}`);
88
86
  }
89
- if (seat.trust) {
90
- bits.push(`trust ${seat.trust}`);
87
+ if (Array.isArray(seat.continueTargets) && seat.continueTargets.length > 0) {
88
+ bits.push(`links ${renderLinkTargets(seat.continueTargets)}`);
91
89
  }
92
90
  if (seat.lastAnswerAt) {
93
91
  bits.push(`last answer ${seat.lastAnswerAt}`);
@@ -103,17 +101,26 @@ function renderSeatStatus(seat) {
103
101
  return output;
104
102
  }
105
103
 
104
+ function renderLinkTargets(targets) {
105
+ return targets
106
+ .map((target) => `${target.seatId}:${target.flowMode}`)
107
+ .join(", ");
108
+ }
109
+
106
110
  function parseSeatOptions(command, args) {
111
+ const seatId = normalizeSeatId(command);
107
112
  let flowMode = "off";
108
113
  let continueSeatId = null;
114
+ let continueTargets = [];
115
+ let index = 0;
109
116
 
110
- for (let index = 0; index < args.length;) {
117
+ for (; index < args.length;) {
111
118
  const token = String(args[index] || "").trim().toLowerCase();
112
119
 
113
120
  if (token === "flow") {
114
- const flowToken = String(args[index + 1] || "").trim().toLowerCase();
115
- if (flowToken === "on" || flowToken === "off") {
116
- flowMode = flowToken;
121
+ const nextFlowMode = parseFlowModeToken(args[index + 1]);
122
+ if (nextFlowMode) {
123
+ flowMode = nextFlowMode;
117
124
  index += 2;
118
125
  continue;
119
126
  }
@@ -122,7 +129,7 @@ function parseSeatOptions(command, args) {
122
129
 
123
130
  if (token === "continue") {
124
131
  const targetSeatId = normalizeSeatId(args[index + 1]);
125
- if (targetSeatId) {
132
+ if (targetSeatId && targetSeatId !== seatId) {
126
133
  continueSeatId = targetSeatId;
127
134
  index += 2;
128
135
  continue;
@@ -130,28 +137,94 @@ function parseSeatOptions(command, args) {
130
137
  break;
131
138
  }
132
139
 
140
+ if (token === "link") {
141
+ const parsed = parseLinkTargets(seatId, args, index + 1);
142
+ if (!parsed) {
143
+ break;
144
+ }
145
+
146
+ continueTargets = mergeTargets(continueTargets, parsed.continueTargets);
147
+ index = parsed.nextIndex;
148
+ continue;
149
+ }
150
+
133
151
  break;
134
152
  }
135
153
 
136
- if (args.length === 0 || (flowMode || continueSeatId !== null) && consumedAllArgs(args, flowMode, continueSeatId)) {
137
- return { flowMode, continueSeatId };
154
+ if (index === args.length) {
155
+ return { flowMode, continueSeatId, continueTargets };
138
156
  }
139
157
 
140
158
  throw new Error(
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.`
159
+ `\`muuuuse ${command}\` accepts \`flow on\` / \`flow off\`, optional \`continue <seat>\`, and optional \`link <seat> flow on|off ...\` groups. Run it directly in the terminal you want to arm.`
142
160
  );
143
161
  }
144
162
 
145
- function consumedAllArgs(args, flowMode, continueSeatId) {
146
- const expected = [];
147
- if (flowMode !== "off" || args.includes("flow")) {
148
- expected.push("flow", flowMode);
163
+ function mergeTargets(currentTargets, nextTargets) {
164
+ const merged = [...currentTargets];
165
+ for (const target of nextTargets) {
166
+ const currentIndex = merged.findIndex((entry) => entry.seatId === target.seatId);
167
+ if (currentIndex !== -1) {
168
+ merged.splice(currentIndex, 1);
169
+ }
170
+ merged.push(target);
171
+ }
172
+ return merged;
173
+ }
174
+
175
+ function parseLinkTargets(seatId, args, startIndex) {
176
+ let index = startIndex;
177
+ const continueTargets = [];
178
+
179
+ while (index < args.length) {
180
+ const targetSeatId = normalizeSeatId(args[index]);
181
+ if (!targetSeatId || targetSeatId === seatId) {
182
+ break;
183
+ }
184
+
185
+ if (String(args[index + 1] || "").trim().toLowerCase() !== "flow") {
186
+ break;
187
+ }
188
+
189
+ const targetFlowMode = parseFlowModeToken(args[index + 2]);
190
+ if (!targetFlowMode) {
191
+ break;
192
+ }
193
+
194
+ upsertTarget(continueTargets, {
195
+ seatId: targetSeatId,
196
+ flowMode: targetFlowMode,
197
+ });
198
+
199
+ index += 3;
200
+ }
201
+
202
+ if (index === startIndex) {
203
+ return null;
204
+ }
205
+
206
+ return {
207
+ continueTargets,
208
+ nextIndex: index,
209
+ };
210
+ }
211
+
212
+ function parseFlowModeToken(value) {
213
+ const token = String(value || "").trim().toLowerCase();
214
+ if (token === "on" || token === "off") {
215
+ return token;
149
216
  }
150
- if (continueSeatId !== null || args.includes("continue")) {
151
- expected.push("continue", String(continueSeatId));
217
+ return null;
218
+ }
219
+
220
+ function upsertTarget(targets, nextTarget) {
221
+ const currentIndex = targets.findIndex((target) => target.seatId === nextTarget.seatId);
222
+ if (currentIndex === -1) {
223
+ targets.push(nextTarget);
224
+ return;
152
225
  }
153
- return expected.length === args.length &&
154
- expected.every((value, index) => String(args[index]).trim().toLowerCase() === String(value).trim().toLowerCase());
226
+
227
+ targets[currentIndex] = nextTarget;
155
228
  }
156
229
 
157
230
  module.exports = {
package/src/runtime.js CHANGED
@@ -20,12 +20,10 @@ const {
20
20
  ensureDir,
21
21
  getDefaultSessionName,
22
22
  getFileSize,
23
- getPartnerSeatId,
24
23
  getSeatPaths,
25
24
  getSessionPaths,
26
25
  getStateRoot,
27
26
  hashText,
28
- isAnchorSeat,
29
27
  isPidAlive,
30
28
  listSeatIds,
31
29
  loadOrCreateSeatIdentity,
@@ -40,7 +38,8 @@ const {
40
38
  writeJson,
41
39
  } = require("./util");
42
40
 
43
- const TYPE_CHUNK_DELAY_MS = 18;
41
+ // A short settle delay keeps interactive CLIs from treating submit as another newline.
42
+ const TYPE_CHUNK_DELAY_MS = 45;
44
43
  const TYPE_CHUNK_SIZE = 24;
45
44
  const MIRROR_SUPPRESSION_WINDOW_MS = 30 * 1000;
46
45
  const PENDING_RELAY_CONTEXT_TTL_MS = 2 * 60 * 1000;
@@ -91,6 +90,26 @@ function normalizeContinueSeatId(value) {
91
90
  return seatId || null;
92
91
  }
93
92
 
93
+ function normalizeContinueTargets(value) {
94
+ if (!Array.isArray(value)) {
95
+ return [];
96
+ }
97
+
98
+ return value
99
+ .map((entry) => {
100
+ const seatId = normalizeSeatId(entry?.seatId);
101
+ if (!seatId) {
102
+ return null;
103
+ }
104
+
105
+ return {
106
+ seatId,
107
+ flowMode: normalizeFlowMode(entry?.flowMode),
108
+ };
109
+ })
110
+ .filter((entry) => entry !== null);
111
+ }
112
+
94
113
  function resolveShell() {
95
114
  const shell = String(process.env.SHELL || "").trim();
96
115
  return shell || "/bin/bash";
@@ -168,50 +187,37 @@ function sleepSync(ms) {
168
187
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
169
188
  }
170
189
 
171
- function findJoinableSessionName(currentPath = process.cwd(), seatId = 2) {
172
- const normalizedSeatId = normalizeSeatId(seatId);
173
- const anchorSeatId = getPartnerSeatId(normalizedSeatId);
174
- if (!normalizedSeatId || !anchorSeatId || isAnchorSeat(normalizedSeatId)) {
175
- return null;
176
- }
177
-
190
+ function findExistingSessionName(currentPath = process.cwd()) {
178
191
  const candidates = listSessionNames()
179
192
  .map((sessionName) => {
180
193
  const sessionPaths = getSessionPaths(sessionName);
181
194
  const controller = readJson(sessionPaths.controllerPath, null);
182
- const anchorPaths = getSeatPaths(sessionName, anchorSeatId);
183
- const seatPaths = getSeatPaths(sessionName, normalizedSeatId);
184
- const anchorMeta = readJson(anchorPaths.metaPath, null);
185
- const anchorStatus = readJson(anchorPaths.statusPath, null);
186
- const seatMeta = readJson(seatPaths.metaPath, null);
187
- const seatStatus = readJson(seatPaths.statusPath, null);
188
- const stopRequest = readJson(sessionPaths.stopPath, null);
195
+ const seats = listSeatIds(sessionName)
196
+ .map((seatId) => buildSeatReport(sessionName, seatId))
197
+ .filter((entry) => entry !== null);
189
198
 
190
- const cwd = controller?.cwd || anchorStatus?.cwd || anchorMeta?.cwd || seatStatus?.cwd || seatMeta?.cwd || null;
199
+ const cwd = controller?.cwd || seats[0]?.cwd || null;
191
200
  if (!matchesWorkingPath(cwd, currentPath)) {
192
201
  return null;
193
202
  }
194
203
 
195
- const anchorWrapperPid = anchorStatus?.pid || anchorMeta?.pid || null;
196
- const anchorChildPid = anchorStatus?.childPid || anchorMeta?.childPid || null;
197
- const seatWrapperPid = seatStatus?.pid || seatMeta?.pid || null;
198
- const seatChildPid = seatStatus?.childPid || seatMeta?.childPid || null;
199
- const anchorLive = isPidAlive(anchorWrapperPid) || isPidAlive(anchorChildPid);
200
- const seatLive = isPidAlive(seatWrapperPid) || isPidAlive(seatChildPid);
201
- const stopRequestedAtMs = Date.parse(stopRequest?.requestedAt || "");
202
- const createdAtMs = Date.parse(controller?.createdAt || anchorMeta?.startedAt || anchorStatus?.updatedAt || "");
203
-
204
- if (!anchorLive || seatLive) {
204
+ const controllerPid = controller?.pid || null;
205
+ const controllerLive = isPidAlive(controllerPid);
206
+ if (seats.length === 0 && !controllerLive) {
205
207
  return null;
206
208
  }
207
209
 
208
- if (Number.isFinite(stopRequestedAtMs) && Number.isFinite(createdAtMs) && stopRequestedAtMs > createdAtMs) {
209
- return null;
210
- }
210
+ const createdAtMs = Date.parse(
211
+ controller?.createdAt ||
212
+ seats
213
+ .map((seat) => seat.startedAt || seat.updatedAt || "")
214
+ .find((value) => value) ||
215
+ ""
216
+ );
211
217
 
212
218
  return {
213
219
  sessionName,
214
- createdAtMs,
220
+ createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : 0,
215
221
  };
216
222
  })
217
223
  .filter((entry) => entry !== null)
@@ -220,10 +226,10 @@ function findJoinableSessionName(currentPath = process.cwd(), seatId = 2) {
220
226
  return candidates[0]?.sessionName || null;
221
227
  }
222
228
 
223
- function waitForJoinableSessionName(currentPath = process.cwd(), seatId = 2, timeoutMs = SEAT_JOIN_WAIT_MS) {
229
+ function waitForExistingSessionName(currentPath = process.cwd(), timeoutMs = SEAT_JOIN_WAIT_MS) {
224
230
  const deadline = Date.now() + timeoutMs;
225
231
  while (Date.now() <= deadline) {
226
- const sessionName = findJoinableSessionName(currentPath, seatId);
232
+ const sessionName = findExistingSessionName(currentPath);
227
233
  if (sessionName) {
228
234
  return sessionName;
229
235
  }
@@ -234,11 +240,31 @@ function waitForJoinableSessionName(currentPath = process.cwd(), seatId = 2, tim
234
240
  }
235
241
 
236
242
  function resolveSessionName(currentPath = process.cwd(), seatId = 1) {
237
- if (isAnchorSeat(seatId)) {
238
- return createSessionName(currentPath);
243
+ const existingSessionName = findExistingSessionName(currentPath);
244
+ if (!existingSessionName) {
245
+ const normalizedSeatId = normalizeSeatId(seatId) || 1;
246
+ const joinWaitMs = Math.min(1000, Math.max(0, normalizedSeatId - 1) * 250);
247
+ const waitedSessionName = waitForExistingSessionName(currentPath, joinWaitMs);
248
+ if (!waitedSessionName) {
249
+ return createSessionName(currentPath);
250
+ }
251
+ const conflictingWaitedSeat = buildSeatReport(waitedSessionName, seatId);
252
+ if (conflictingWaitedSeat) {
253
+ throw new Error(
254
+ `Seat ${seatId} is already armed in this cwd. Stop it first or choose another seat number.`
255
+ );
256
+ }
257
+ return waitedSessionName;
258
+ }
259
+
260
+ const conflictingSeat = buildSeatReport(existingSessionName, seatId);
261
+ if (conflictingSeat) {
262
+ throw new Error(
263
+ `Seat ${seatId} is already armed in this cwd. Stop it first or choose another seat number.`
264
+ );
239
265
  }
240
266
 
241
- return waitForJoinableSessionName(currentPath, seatId);
267
+ return existingSessionName;
242
268
  }
243
269
 
244
270
  function parseAnswerEntries(text) {
@@ -440,49 +466,30 @@ function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, pr
440
466
  return null;
441
467
  }
442
468
 
443
- function buildClaimMessage(sessionName, challenge, seat1PublicKey, seat2PublicKey) {
444
- return JSON.stringify({
445
- type: "muuuuse_pair_claim",
446
- sessionName,
447
- challenge,
448
- seat1PublicKey,
449
- seat2PublicKey,
450
- });
451
- }
452
-
453
- function buildAckMessage(sessionName, challenge, seat1PublicKey, seat2PublicKey) {
454
- return JSON.stringify({
455
- type: "muuuuse_pair_ack",
456
- sessionName,
457
- challenge,
458
- seat1PublicKey,
459
- seat2PublicKey,
460
- });
461
- }
462
-
463
469
  function buildAnswerSignaturePayload(sessionName, challenge, entry) {
464
470
  return JSON.stringify({
465
- type: "muuuuse_answer",
471
+ type: "muuuuse_relay",
466
472
  sessionName,
467
- challenge,
468
473
  chainId: entry.chainId,
469
474
  hop: entry.hop,
470
475
  id: entry.id,
471
- seatId: entry.seatId,
476
+ sourceSeatId: normalizeSeatId(entry.sourceSeatId || entry.seatId),
477
+ targetSeatId: normalizeSeatId(entry.targetSeatId),
472
478
  origin: entry.origin,
473
- phase: entry.phase || "final_answer",
479
+ phase: getRelayPhase(entry),
474
480
  createdAt: entry.createdAt,
475
481
  text: entry.text,
476
482
  });
477
483
  }
478
484
 
479
- function buildContinuationEntry(sourceSessionName, targetSeatId, entry) {
485
+ function buildContinuationEntry(sourceSessionName, targetSeatId, entry, targetFlowMode = null) {
480
486
  return {
481
487
  id: createId(12),
482
488
  type: "continue",
483
489
  sourceSessionName,
484
490
  sourceSeatId: entry.seatId,
485
491
  targetSeatId,
492
+ targetFlowMode: normalizeFlowMode(targetFlowMode),
486
493
  origin: entry.origin || "unknown",
487
494
  phase: entry.phase || "final_answer",
488
495
  text: entry.text,
@@ -516,24 +523,6 @@ function getSeatDirIfExists(sessionName, seatId) {
516
523
  return null;
517
524
  }
518
525
 
519
- function readSeatChallenge(paths, sessionName) {
520
- const record = readJson(paths.challengePath, null);
521
- if (
522
- !record ||
523
- record.sessionName !== sessionName ||
524
- typeof record.challenge !== "string" ||
525
- typeof record.publicKey !== "string"
526
- ) {
527
- return null;
528
- }
529
-
530
- return {
531
- challenge: record.challenge,
532
- publicKey: record.publicKey.trim(),
533
- createdAt: record.createdAt || null,
534
- };
535
- }
536
-
537
526
  function normalizeRelayPayloadForTyping(text) {
538
527
  return String(text || "")
539
528
  .replace(/\r/g, "")
@@ -599,7 +588,7 @@ async function sendTextAndEnter(child, text, shouldAbort = () => false) {
599
588
  }
600
589
 
601
590
  try {
602
- child.write("\n");
591
+ child.write("\r");
603
592
  } catch {
604
593
  return false;
605
594
  }
@@ -610,25 +599,20 @@ async function sendTextAndEnter(child, text, shouldAbort = () => false) {
610
599
  class ArmedSeat {
611
600
  constructor(options) {
612
601
  this.seatId = options.seatId;
613
- this.partnerSeatId = getPartnerSeatId(options.seatId);
614
- this.anchorSeatId = isAnchorSeat(options.seatId) ? options.seatId : this.partnerSeatId;
615
602
  this.flowMode = normalizeFlowMode(options.flowMode);
616
603
  this.continueSeatId = normalizeContinueSeatId(options.continueSeatId);
604
+ this.continueTargets = normalizeContinueTargets(options.continueTargets);
617
605
  this.cwd = normalizeWorkingPath(options.cwd);
618
606
  if (this.continueSeatId === this.seatId) {
619
607
  throw new Error(`\`muuuuse ${this.seatId}\` cannot continue to itself.`);
620
608
  }
621
- this.sessionName = resolveSessionName(this.cwd, this.seatId);
622
- if (!this.sessionName) {
623
- throw new Error(
624
- `No armed \`muuuuse ${this.partnerSeatId}\` seat is waiting in this cwd. Run \`muuuuse ${this.partnerSeatId}\` first.`
625
- );
609
+ if (this.continueTargets.some((target) => target.seatId === this.seatId)) {
610
+ throw new Error(`\`muuuuse ${this.seatId}\` cannot link to itself.`);
626
611
  }
612
+ this.sessionName = resolveSessionName(this.cwd, this.seatId);
627
613
  this.sessionPaths = getSessionPaths(this.sessionName);
628
614
  this.paths = getSeatPaths(this.sessionName, this.seatId);
629
- this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
630
615
  this.continueOffset = getFileSize(this.paths.continuePath);
631
- this.partnerOffset = getFileSize(this.partnerPaths.eventsPath);
632
616
 
633
617
  this.child = null;
634
618
  this.childPid = null;
@@ -641,17 +625,11 @@ class ArmedSeat {
641
625
  this.stdinCleanup = null;
642
626
  this.resizeCleanup = null;
643
627
  this.forceKillTimer = null;
644
- this.identity = null;
628
+ this.identity = loadOrCreateSeatIdentity(this.paths);
645
629
  this.lastUserInputAtMs = 0;
646
630
  this.pendingInboundContext = null;
647
631
  this.recentInboundRelays = [];
648
632
  this.recentEmittedAnswers = [];
649
- this.trustState = {
650
- challenge: null,
651
- peerPublicKey: null,
652
- phase: isAnchorSeat(this.seatId) ? "waiting_for_peer_signature" : "waiting_for_anchor_key",
653
- pairedAt: null,
654
- };
655
633
  this.liveState = {
656
634
  type: null,
657
635
  pid: null,
@@ -672,11 +650,7 @@ class ArmedSeat {
672
650
  cwd: this.cwd,
673
651
  createdAt: current.createdAt || this.startedAt,
674
652
  updatedAt: new Date().toISOString(),
675
- anchorSeatId: this.anchorSeatId,
676
- partnerSeatId: this.partnerSeatId,
677
- anchorSeatPid: this.seatId === this.anchorSeatId ? process.pid : current.anchorSeatPid || null,
678
- partnerSeatPid: this.seatId === this.partnerSeatId ? process.pid : current.partnerSeatPid || null,
679
- pid: this.seatId === this.anchorSeatId ? process.pid : current.pid || null,
653
+ pid: process.pid,
680
654
  ...extra,
681
655
  });
682
656
  }
@@ -688,13 +662,14 @@ class ArmedSeat {
688
662
  writeMeta(extra = {}) {
689
663
  writeJson(this.paths.metaPath, {
690
664
  seatId: this.seatId,
691
- partnerSeatId: this.partnerSeatId,
692
665
  sessionName: this.sessionName,
693
666
  flowMode: this.flowMode,
694
667
  continueSeatId: this.continueSeatId,
668
+ continueTargets: this.continueTargets,
695
669
  cwd: this.cwd,
696
670
  pid: process.pid,
697
671
  childPid: this.childPid,
672
+ publicKey: this.identity?.publicKey || null,
698
673
  command: [resolveShell(), ...resolveShellArgs(resolveShell())],
699
674
  startedAt: this.startedAt,
700
675
  ...extra,
@@ -704,190 +679,24 @@ class ArmedSeat {
704
679
  writeStatus(extra = {}) {
705
680
  writeJson(this.paths.statusPath, {
706
681
  seatId: this.seatId,
707
- partnerSeatId: this.partnerSeatId,
708
682
  sessionName: this.sessionName,
709
683
  flowMode: this.flowMode,
710
684
  continueSeatId: this.continueSeatId,
685
+ continueTargets: this.continueTargets,
711
686
  cwd: this.cwd,
712
687
  pid: process.pid,
713
688
  childPid: this.childPid,
689
+ publicKey: this.identity?.publicKey || null,
714
690
  relayCount: this.relayCount,
715
691
  updatedAt: new Date().toISOString(),
716
692
  ...extra,
717
693
  });
718
694
  }
719
695
 
720
- initializeTrustMaterial() {
721
- this.identity = loadOrCreateSeatIdentity(this.paths);
722
-
723
- if (!isAnchorSeat(this.seatId)) {
724
- return;
725
- }
726
-
727
- writeJson(this.paths.challengePath, {
728
- sessionName: this.sessionName,
729
- challenge: createId(48),
730
- publicKey: this.identity.publicKey,
731
- createdAt: new Date().toISOString(),
732
- });
733
- this.trustState.challenge = readSeatChallenge(this.paths, this.sessionName)?.challenge || null;
734
- this.trustState.peerPublicKey = null;
735
- this.trustState.phase = "waiting_for_peer_signature";
736
- this.trustState.pairedAt = null;
737
- fs.rmSync(this.paths.ackPath, { force: true });
738
- fs.rmSync(this.partnerPaths.claimPath, { force: true });
739
- }
740
-
741
- syncTrustState() {
742
- if (!this.identity) {
743
- this.initializeTrustMaterial();
744
- }
745
-
746
- if (isAnchorSeat(this.seatId)) {
747
- this.syncSeatOneTrust();
748
- return;
749
- }
750
-
751
- this.syncSeatTwoTrust();
752
- }
753
-
754
- syncSeatOneTrust() {
755
- const challengeRecord = readSeatChallenge(this.paths, this.sessionName);
756
- if (!challengeRecord || challengeRecord.publicKey !== this.identity.publicKey) {
757
- this.trustState = {
758
- challenge: null,
759
- peerPublicKey: null,
760
- phase: "waiting_for_peer_signature",
761
- pairedAt: null,
762
- };
763
- return;
764
- }
765
-
766
- this.trustState.challenge = challengeRecord.challenge;
767
- const claim = readJson(this.partnerPaths.claimPath, null);
768
- if (
769
- !claim ||
770
- claim.sessionName !== this.sessionName ||
771
- claim.challenge !== challengeRecord.challenge ||
772
- typeof claim.publicKey !== "string" ||
773
- typeof claim.signature !== "string" ||
774
- !verifyText(
775
- buildClaimMessage(
776
- this.sessionName,
777
- challengeRecord.challenge,
778
- this.identity.publicKey,
779
- claim.publicKey.trim()
780
- ),
781
- claim.signature,
782
- claim.publicKey
783
- )
784
- ) {
785
- this.trustState.peerPublicKey = null;
786
- this.trustState.phase = "waiting_for_peer_signature";
787
- this.trustState.pairedAt = null;
788
- fs.rmSync(this.paths.ackPath, { force: true });
789
- return;
790
- }
791
-
792
- const peerPublicKey = claim.publicKey.trim();
793
- const ackMessage = buildAckMessage(this.sessionName, challengeRecord.challenge, this.identity.publicKey, peerPublicKey);
794
- const currentAck = readJson(this.paths.ackPath, null);
795
- const ackIsValid = Boolean(
796
- currentAck &&
797
- currentAck.sessionName === this.sessionName &&
798
- currentAck.challenge === challengeRecord.challenge &&
799
- currentAck.publicKey === this.identity.publicKey &&
800
- currentAck.peerPublicKey === peerPublicKey &&
801
- typeof currentAck.signature === "string" &&
802
- verifyText(ackMessage, currentAck.signature, this.identity.publicKey)
803
- );
804
- if (!ackIsValid) {
805
- writeJson(this.paths.ackPath, {
806
- sessionName: this.sessionName,
807
- challenge: challengeRecord.challenge,
808
- publicKey: this.identity.publicKey,
809
- peerPublicKey,
810
- signature: signText(ackMessage, this.identity.privateKey),
811
- signedAt: new Date().toISOString(),
812
- });
813
- }
814
-
815
- const ackRecord = ackIsValid ? currentAck : readJson(this.paths.ackPath, null);
816
- this.trustState.peerPublicKey = peerPublicKey;
817
- this.trustState.phase = "paired";
818
- this.trustState.pairedAt = ackRecord?.signedAt || new Date().toISOString();
819
- }
820
-
821
- syncSeatTwoTrust() {
822
- const challengeRecord = readSeatChallenge(this.partnerPaths, this.sessionName);
823
- if (!challengeRecord) {
824
- this.trustState = {
825
- challenge: null,
826
- peerPublicKey: null,
827
- phase: "waiting_for_anchor_key",
828
- pairedAt: null,
829
- };
830
- return;
831
- }
832
-
833
- const challenge = challengeRecord.challenge;
834
- const peerPublicKey = challengeRecord.publicKey;
835
- const claimPayload = {
836
- sessionName: this.sessionName,
837
- challenge,
838
- publicKey: this.identity.publicKey,
839
- };
840
- const claimSignature = signText(
841
- buildClaimMessage(this.sessionName, challenge, peerPublicKey, this.identity.publicKey),
842
- this.identity.privateKey
843
- );
844
- const currentClaim = readJson(this.paths.claimPath, null);
845
- if (
846
- !currentClaim ||
847
- currentClaim.sessionName !== claimPayload.sessionName ||
848
- currentClaim.challenge !== claimPayload.challenge ||
849
- currentClaim.publicKey !== claimPayload.publicKey ||
850
- currentClaim.signature !== claimSignature
851
- ) {
852
- writeJson(this.paths.claimPath, {
853
- ...claimPayload,
854
- signature: claimSignature,
855
- signedAt: new Date().toISOString(),
856
- });
857
- }
858
-
859
- const ack = readJson(this.partnerPaths.ackPath, null);
860
- const paired = Boolean(
861
- ack &&
862
- ack.sessionName === this.sessionName &&
863
- ack.challenge === challenge &&
864
- ack.peerPublicKey === this.identity.publicKey &&
865
- ack.publicKey === peerPublicKey &&
866
- typeof ack.signature === "string" &&
867
- verifyText(
868
- buildAckMessage(this.sessionName, challenge, peerPublicKey, this.identity.publicKey),
869
- ack.signature,
870
- peerPublicKey
871
- )
872
- );
873
-
874
- this.trustState.challenge = challenge;
875
- this.trustState.peerPublicKey = peerPublicKey;
876
- this.trustState.phase = paired ? "paired" : "waiting_for_pair_ack";
877
- this.trustState.pairedAt = paired ? (ack.signedAt || new Date().toISOString()) : null;
878
- }
879
-
880
- isPaired() {
881
- return this.trustState.phase === "paired" &&
882
- typeof this.trustState.challenge === "string" &&
883
- typeof this.trustState.peerPublicKey === "string";
884
- }
885
-
886
696
  launchShell() {
887
697
  ensureDir(this.paths.dir);
888
698
  fs.rmSync(this.paths.pipePath, { force: true });
889
699
  clearStaleStopRequest(this.sessionPaths.stopPath, this.startedAtMs);
890
- this.initializeTrustMaterial();
891
700
  this.writeController();
892
701
 
893
702
  const shell = resolveShell();
@@ -904,7 +713,7 @@ class ArmedSeat {
904
713
 
905
714
  this.childPid = this.child.pid;
906
715
  this.writeMeta();
907
- this.writeStatus({ state: "running", trust: this.trustState.phase });
716
+ this.writeStatus({ state: "running" });
908
717
 
909
718
  this.child.onData((data) => {
910
719
  fs.appendFileSync(this.paths.pipePath, data);
@@ -1038,9 +847,19 @@ class ArmedSeat {
1038
847
  }
1039
848
  }
1040
849
 
1041
- partnerIsLive() {
1042
- const partner = readJson(this.partnerPaths.statusPath, null);
1043
- return Boolean(partner?.pid && isPidAlive(partner.pid));
850
+ getConfiguredTargets() {
851
+ const targets = [...this.continueTargets];
852
+ if (this.continueSeatId && !targets.some((target) => target.seatId === this.continueSeatId)) {
853
+ targets.push({
854
+ seatId: this.continueSeatId,
855
+ flowMode: this.flowMode,
856
+ });
857
+ }
858
+ return targets;
859
+ }
860
+
861
+ shouldCaptureCommentary() {
862
+ return this.getConfiguredTargets().some((target) => target.flowMode === "on");
1044
863
  }
1045
864
 
1046
865
  stopRequested() {
@@ -1053,109 +872,80 @@ class ArmedSeat {
1053
872
  return Number.isFinite(requestedAtMs) && requestedAtMs > this.startedAtMs;
1054
873
  }
1055
874
 
1056
- findContinuationTarget() {
1057
- if (!this.continueSeatId) {
875
+ hasAuthorizedSource(sourceSeatId) {
876
+ const desiredSeatId = normalizeSeatId(sourceSeatId);
877
+ if (!desiredSeatId) {
878
+ return false;
879
+ }
880
+
881
+ return this.getConfiguredTargets().some((target) => target.seatId === desiredSeatId);
882
+ }
883
+
884
+ readSourcePublicKey(sourceSeatId) {
885
+ const desiredSeatId = normalizeSeatId(sourceSeatId);
886
+ if (!desiredSeatId) {
1058
887
  return null;
1059
888
  }
1060
889
 
1061
- const candidates = listSessionNames()
1062
- .map((sessionName) => {
1063
- if (!getSeatDirIfExists(sessionName, this.continueSeatId)) {
1064
- return null;
1065
- }
890
+ const sourcePaths = getSeatPaths(this.sessionName, desiredSeatId);
891
+ const sourceMeta = readJson(sourcePaths.metaPath, null);
892
+ if (typeof sourceMeta?.publicKey === "string" && sourceMeta.publicKey.trim()) {
893
+ return sourceMeta.publicKey.trim();
894
+ }
1066
895
 
1067
- const seat = buildSeatReport(sessionName, this.continueSeatId);
1068
- if (!seat || !matchesWorkingPath(seat.cwd, this.cwd)) {
1069
- return null;
1070
- }
896
+ try {
897
+ const key = fs.readFileSync(sourcePaths.publicKeyPath, "utf8").trim();
898
+ return key || null;
899
+ } catch {
900
+ return null;
901
+ }
902
+ }
1071
903
 
1072
- const updatedAtMs = Date.parse(seat.updatedAt || seat.startedAt || "");
1073
- return {
1074
- seat,
1075
- sessionName,
1076
- updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : 0,
1077
- };
1078
- })
1079
- .filter((entry) => entry !== null)
1080
- .sort((left, right) => right.updatedAtMs - left.updatedAtMs);
904
+ findLinkedTarget(targetSeatId) {
905
+ const desiredSeatId = normalizeContinueSeatId(targetSeatId);
906
+ if (!desiredSeatId) {
907
+ return null;
908
+ }
1081
909
 
1082
- if (candidates.length === 0) {
910
+ const seat = buildSeatReport(this.sessionName, desiredSeatId);
911
+ if (!seat || !matchesWorkingPath(seat.cwd, this.cwd)) {
1083
912
  return null;
1084
913
  }
1085
914
 
1086
- const target = candidates[0];
1087
915
  return {
1088
- seatId: target.seat.seatId,
1089
- sessionName: target.sessionName,
1090
- paths: getSeatPaths(target.sessionName, target.seat.seatId),
916
+ seatId: seat.seatId,
917
+ paths: getSeatPaths(this.sessionName, seat.seatId),
1091
918
  };
1092
919
  }
1093
920
 
1094
- async pullPartnerEvents() {
1095
- const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
1096
- this.partnerOffset = nextOffset;
1097
- if (!text.trim() || !this.child || this.stopped || !this.isPaired()) {
1098
- return;
921
+ verifyInboundEntry(entry) {
922
+ const sourceSeatId = normalizeSeatId(entry?.sourceSeatId || entry?.seatId);
923
+ const targetSeatId = normalizeSeatId(entry?.targetSeatId);
924
+ const payload = sanitizeRelayText(entry?.text);
925
+ if (!sourceSeatId || targetSeatId !== this.seatId || !payload || !this.hasAuthorizedSource(sourceSeatId)) {
926
+ return false;
1099
927
  }
1100
928
 
1101
- const entries = parseAnswerEntries(text);
1102
- for (const entry of entries) {
1103
- if (this.stopped || this.stopRequested()) {
1104
- this.requestStop("stop_requested");
1105
- return;
1106
- }
1107
-
1108
- if (!shouldAcceptInboundEntry(this.flowMode, entry)) {
1109
- continue;
1110
- }
929
+ const publicKey = this.readSourcePublicKey(sourceSeatId);
930
+ if (!publicKey || entry.publicKey !== publicKey || typeof entry.signature !== "string") {
931
+ return false;
932
+ }
1111
933
 
1112
- const payload = sanitizeRelayText(entry.text);
1113
- const signaturePayload = buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, {
934
+ return verifyText(
935
+ buildAnswerSignaturePayload(this.sessionName, null, {
936
+ id: entry.id,
937
+ sourceSeatId,
938
+ targetSeatId,
1114
939
  chainId: entry.chainId || entry.id,
1115
940
  hop: Number.isInteger(entry.hop) ? entry.hop : 0,
1116
- id: entry.id,
1117
- seatId: entry.seatId,
1118
941
  origin: entry.origin || "unknown",
1119
942
  phase: getRelayPhase(entry),
1120
943
  createdAt: entry.createdAt,
1121
944
  text: payload,
1122
- });
1123
- if (
1124
- !payload ||
1125
- entry.challenge !== this.trustState.challenge ||
1126
- entry.publicKey !== this.trustState.peerPublicKey ||
1127
- typeof entry.signature !== "string" ||
1128
- !verifyText(signaturePayload, entry.signature, this.trustState.peerPublicKey)
1129
- ) {
1130
- continue;
1131
- }
1132
-
1133
- const delivered = await sendTextAndEnter(
1134
- this.child,
1135
- payload,
1136
- () => this.stopped || this.stopRequested() || !this.child || Boolean(this.childExit)
1137
- );
1138
- if (!delivered) {
1139
- this.requestStop("relay_aborted");
1140
- return;
1141
- }
1142
-
1143
- if (this.stopped || this.stopRequested()) {
1144
- this.requestStop("stop_requested");
1145
- return;
1146
- }
1147
-
1148
- const deliveredAtMs = Date.now();
1149
- this.pendingInboundContext = {
1150
- chainId: entry.chainId || entry.id,
1151
- deliveredAtMs,
1152
- expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
1153
- hop: Number.isInteger(entry.hop) ? entry.hop : 0,
1154
- };
1155
- this.relayCount += 1;
1156
- this.rememberInboundRelay(payload);
1157
- this.log(`[${this.partnerSeatId} -> ${this.seatId}] ${previewText(payload)}`);
1158
- }
945
+ }),
946
+ entry.signature,
947
+ publicKey
948
+ );
1159
949
  }
1160
950
 
1161
951
  async pullContinuationEvents() {
@@ -1172,7 +962,7 @@ class ArmedSeat {
1172
962
  return;
1173
963
  }
1174
964
 
1175
- if (!shouldAcceptInboundEntry(this.flowMode, entry)) {
965
+ if (!this.verifyInboundEntry(entry)) {
1176
966
  continue;
1177
967
  }
1178
968
 
@@ -1389,7 +1179,7 @@ class ArmedSeat {
1389
1179
  this.liveState.sessionFile,
1390
1180
  this.liveState.offset,
1391
1181
  this.liveState.captureSinceMs,
1392
- { flowMode: this.flowMode === "on" }
1182
+ { flowMode: this.shouldCaptureCommentary() }
1393
1183
  );
1394
1184
  this.liveState.offset = result.nextOffset;
1395
1185
  answers.push(...result.answers);
@@ -1398,7 +1188,7 @@ class ArmedSeat {
1398
1188
  this.liveState.sessionFile,
1399
1189
  this.liveState.offset,
1400
1190
  this.liveState.captureSinceMs,
1401
- { flowMode: this.flowMode === "on" }
1191
+ { flowMode: this.shouldCaptureCommentary() }
1402
1192
  );
1403
1193
  this.liveState.offset = result.nextOffset;
1404
1194
  answers.push(...result.answers);
@@ -1407,7 +1197,7 @@ class ArmedSeat {
1407
1197
  this.liveState.sessionFile,
1408
1198
  this.liveState.lastMessageId,
1409
1199
  this.liveState.captureSinceMs,
1410
- { flowMode: this.flowMode === "on" }
1200
+ { flowMode: this.shouldCaptureCommentary() }
1411
1201
  );
1412
1202
  this.liveState.lastMessageId = result.lastMessageId;
1413
1203
  this.liveState.offset = result.fileSize;
@@ -1440,7 +1230,7 @@ class ArmedSeat {
1440
1230
  }
1441
1231
 
1442
1232
  const payload = sanitizeRelayText(entry.text);
1443
- if (!payload || !this.identity || !this.trustState.challenge) {
1233
+ if (!payload) {
1444
1234
  return;
1445
1235
  }
1446
1236
 
@@ -1459,59 +1249,66 @@ class ArmedSeat {
1459
1249
  const pendingInboundContext = this.getPendingInboundContext();
1460
1250
 
1461
1251
  const entryId = entry.id || createId(12);
1462
- const signedEntry = {
1252
+ const relayEntry = {
1463
1253
  id: entryId,
1464
1254
  type: "answer",
1465
1255
  seatId: this.seatId,
1256
+ sourceSeatId: this.seatId,
1466
1257
  origin: entry.origin || "unknown",
1467
1258
  phase: entry.phase || "final_answer",
1468
1259
  text: payload,
1469
1260
  createdAt: entry.createdAt || new Date().toISOString(),
1470
1261
  chainId: pendingInboundContext?.chainId || entry.chainId || entryId,
1471
1262
  hop: pendingInboundContext ? pendingInboundContext.hop + 1 : 0,
1472
- challenge: this.trustState.challenge,
1473
- publicKey: this.identity.publicKey,
1474
1263
  };
1475
- signedEntry.signature = signText(
1476
- buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, signedEntry),
1477
- this.identity.privateKey
1478
- );
1479
- appendJsonl(this.paths.eventsPath, signedEntry);
1480
- this.forwardContinuation(signedEntry);
1264
+
1265
+ appendJsonl(this.paths.eventsPath, relayEntry);
1266
+ this.forwardContinuation(relayEntry);
1481
1267
  this.rememberEmittedAnswer(answerKey);
1482
1268
 
1483
1269
  this.log(`[${this.seatId}] ${previewText(payload)}`);
1484
1270
  }
1485
1271
 
1486
- forwardContinuation(signedEntry) {
1487
- if (!this.continueSeatId) {
1272
+ forwardContinuation(relayEntry) {
1273
+ const targets = this.getConfiguredTargets();
1274
+ if (targets.length === 0) {
1488
1275
  return;
1489
1276
  }
1490
1277
 
1491
- const target = this.findContinuationTarget();
1492
- if (!target) {
1493
- this.log(`[${this.seatId}] continue ${this.continueSeatId} unavailable`);
1494
- return;
1495
- }
1278
+ for (const targetEntry of targets) {
1279
+ if (!shouldAcceptInboundEntry(targetEntry.flowMode, relayEntry)) {
1280
+ continue;
1281
+ }
1282
+
1283
+ const target = this.findLinkedTarget(targetEntry.seatId);
1284
+ if (!target) {
1285
+ this.log(`[${this.seatId}] link ${targetEntry.seatId} unavailable`);
1286
+ continue;
1287
+ }
1496
1288
 
1497
- const continuationEntry = buildContinuationEntry(this.sessionName, target.seatId, signedEntry);
1498
- appendJsonl(target.paths.continuePath, continuationEntry);
1499
- this.log(`[${this.seatId} => ${target.seatId}] ${previewText(continuationEntry.text)}`);
1289
+ const continuationEntry = buildContinuationEntry(
1290
+ this.sessionName,
1291
+ target.seatId,
1292
+ relayEntry,
1293
+ targetEntry.flowMode
1294
+ );
1295
+ continuationEntry.publicKey = this.identity.publicKey;
1296
+ continuationEntry.signature = signText(
1297
+ buildAnswerSignaturePayload(this.sessionName, null, continuationEntry),
1298
+ this.identity.privateKey
1299
+ );
1300
+ appendJsonl(target.paths.continuePath, continuationEntry);
1301
+ this.log(`[${this.seatId} => ${target.seatId}] ${previewText(continuationEntry.text)}`);
1302
+ }
1500
1303
  }
1501
1304
 
1502
1305
  async tick() {
1503
1306
  if (this.stopRequested()) {
1504
- this.writeStatus({
1505
- state: "stopping",
1506
- partnerLive: this.partnerIsLive(),
1507
- trust: this.trustState.phase,
1508
- });
1307
+ this.writeStatus({ state: "stopping" });
1509
1308
  this.requestStop("stop_requested");
1510
1309
  return;
1511
1310
  }
1512
1311
 
1513
- this.syncTrustState();
1514
- await this.pullPartnerEvents();
1515
1312
  await this.pullContinuationEvents();
1516
1313
  if (this.stopped || this.stopRequested()) {
1517
1314
  this.requestStop("stop_requested");
@@ -1530,9 +1327,6 @@ class ArmedSeat {
1530
1327
  cwd: live.cwd,
1531
1328
  log: live.log,
1532
1329
  lastAnswerAt: live.lastAnswerAt,
1533
- partnerLive: this.partnerIsLive(),
1534
- trust: this.trustState.phase,
1535
- challengeReady: Boolean(this.trustState.challenge),
1536
1330
  });
1537
1331
  }
1538
1332
 
@@ -1544,15 +1338,17 @@ class ArmedSeat {
1544
1338
 
1545
1339
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
1546
1340
  this.log("Use this shell normally. Codex, Claude, and Gemini relay automatically from their local session logs.");
1547
- this.log(`Seat ${this.seatId} relay mode is flow ${this.flowMode}.`);
1341
+ this.log(`Seat ${this.seatId} default relay mode is flow ${this.flowMode}.`);
1548
1342
  if (this.continueSeatId) {
1549
1343
  this.log(`Seat ${this.seatId} continues to seat ${this.continueSeatId}.`);
1550
1344
  }
1551
- if (isAnchorSeat(this.seatId)) {
1552
- this.log(`Seat ${this.seatId} generated the session key and is waiting for seat ${this.partnerSeatId} to sign it.`);
1553
- } else {
1554
- this.log(`Seat ${this.seatId} will sign the session key from seat ${this.partnerSeatId}, then relay goes live.`);
1345
+ const configuredTargets = this.getConfiguredTargets();
1346
+ if (configuredTargets.length > 0) {
1347
+ this.log(
1348
+ `Seat ${this.seatId} links signed relay targets: ${configuredTargets.map((target) => `${target.seatId}:${target.flowMode}`).join(", ")}.`
1349
+ );
1555
1350
  }
1351
+ this.log("Signed relays are accepted only from seats that this seat links back to.");
1556
1352
  this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
1557
1353
 
1558
1354
  try {
@@ -1650,6 +1446,7 @@ function buildSeatReport(sessionName, seatId) {
1650
1446
  state: wrapperLive ? status?.state || "running" : "orphaned_child",
1651
1447
  flowMode: status?.flowMode || meta?.flowMode || "off",
1652
1448
  continueSeatId: status?.continueSeatId || meta?.continueSeatId || null,
1449
+ continueTargets: normalizeContinueTargets(status?.continueTargets || meta?.continueTargets),
1653
1450
  wrapperPid,
1654
1451
  childPid,
1655
1452
  wrapperLive,
@@ -1660,10 +1457,8 @@ function buildSeatReport(sessionName, seatId) {
1660
1457
  relayCount: status?.relayCount || 0,
1661
1458
  log: status?.log || null,
1662
1459
  startedAt: meta?.startedAt || null,
1663
- trust: status?.trust || null,
1664
1460
  updatedAt: status?.updatedAt || null,
1665
1461
  lastAnswerAt: status?.lastAnswerAt || null,
1666
- partnerLive: Boolean(status?.partnerLive),
1667
1462
  };
1668
1463
  }
1669
1464
 
package/src/util.js CHANGED
@@ -175,18 +175,6 @@ function normalizeSeatId(value) {
175
175
  return seatId;
176
176
  }
177
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
178
  function listSeatIds(sessionName) {
191
179
  const sessionDir = getSessionDir(sessionName);
192
180
  try {
@@ -282,17 +270,19 @@ function listSessionNames() {
282
270
 
283
271
  function usage() {
284
272
  return [
285
- `${BRAND} arms regular terminals in isolated odd/even pairs and relays assistant output between each pair.`,
273
+ `${BRAND} arms regular terminals and relays assistant output across signed terminal links.`,
286
274
  "",
287
275
  "Usage:",
288
276
  " muuuuse 1",
289
277
  " muuuuse 1 flow on",
290
278
  " muuuuse 1 flow off",
291
279
  " muuuuse 1 flow on continue 3",
280
+ " muuuuse 1 link 2 flow on 3 flow off 5 flow off",
292
281
  " muuuuse 2",
293
282
  " muuuuse 2 flow on",
294
283
  " muuuuse 2 flow off",
295
284
  " muuuuse 2 flow on continue 3",
285
+ " muuuuse 2 link 3 flow off",
296
286
  " muuuuse 3",
297
287
  " muuuuse 4",
298
288
  " muuuuse 4 flow on continue 1",
@@ -300,14 +290,14 @@ function usage() {
300
290
  " muuuuse status",
301
291
  "",
302
292
  "Flow:",
303
- " 1. Run `muuuuse 1` in terminal one.",
304
- " 2. Run `muuuuse 2` in terminal two.",
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.",
293
+ " 1. Run `muuuuse <seat>` in the terminal you want to arm.",
294
+ " 2. All armed seats in the same cwd join one relay graph.",
295
+ " 3. Use `link <seat> flow on|off ...` to define each outbound relay edge.",
296
+ " 4. `flow on` sends commentary and final answers on that edge. `flow off` sends final answers only.",
297
+ " 5. `continue <seat>` is shorthand for one outbound link that uses the seat's default `flow on|off`.",
298
+ " 6. Every forwarded relay is signed with the sender seat's key.",
299
+ " 7. A seat only accepts signed inbound relays from seats it links back to.",
300
+ " 8. Use those armed shells normally.",
311
301
  " 9. Run `muuuuse status` or `muuuuse stop` from any shell.",
312
302
  "",
313
303
  "Notes:",
@@ -325,9 +315,7 @@ module.exports = {
325
315
  ensureDir,
326
316
  getDefaultSessionName,
327
317
  getFileSize,
328
- getPartnerSeatId,
329
318
  loadOrCreateSeatIdentity,
330
- isAnchorSeat,
331
319
  getSeatPaths,
332
320
  getSessionPaths,
333
321
  getStateRoot,