muuuuse 1.4.2 → 2.1.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/src/runtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require("node:fs");
2
2
  const { execFileSync } = require("node:child_process");
3
+ const path = require("node:path");
3
4
  const pty = require("node-pty");
4
5
 
5
6
  const {
@@ -23,18 +24,32 @@ const {
23
24
  getSessionPaths,
24
25
  hashText,
25
26
  isPidAlive,
27
+ loadOrCreateSeatIdentity,
26
28
  listSessionNames,
27
29
  readAppendedText,
28
30
  readJson,
29
31
  sanitizeRelayText,
32
+ signText,
30
33
  sleep,
34
+ verifyText,
31
35
  writeJson,
32
36
  } = require("./util");
33
37
 
34
38
  const TYPE_DELAY_MS = 70;
35
39
  const MIRROR_SUPPRESSION_WINDOW_MS = 30 * 1000;
40
+ const PENDING_RELAY_CONTEXT_TTL_MS = 2 * 60 * 1000;
41
+ const EMITTED_ANSWER_TTL_MS = 5 * 60 * 1000;
36
42
  const MAX_RECENT_INBOUND_RELAYS = 12;
43
+ const MAX_RECENT_EMITTED_ANSWERS = 48;
44
+ const MAX_RELAY_CHAIN_HOP = 1;
37
45
  const STOP_FORCE_KILL_MS = 1200;
46
+ const SEAT_JOIN_WAIT_MS = 3000;
47
+ const SEAT_JOIN_POLL_MS = 60;
48
+ const CHILD_ENV_DROP_KEYS = [
49
+ "CODEX_CI",
50
+ "CODEX_MANAGED_BY_NPM",
51
+ "CODEX_THREAD_ID",
52
+ ];
38
53
 
39
54
  function resolveShell() {
40
55
  const shell = String(process.env.SHELL || "").trim();
@@ -49,16 +64,139 @@ function resolveShellArgs(shellPath) {
49
64
  return [];
50
65
  }
51
66
 
52
- function resolveChildTerm() {
53
- const inherited = String(process.env.TERM || "").trim();
67
+ function resolveChildTerm(sourceEnv = process.env) {
68
+ const inherited = String(sourceEnv.TERM || "").trim();
54
69
  if (inherited && inherited.toLowerCase() !== "dumb") {
55
70
  return inherited;
56
71
  }
57
72
  return "xterm-256color";
58
73
  }
59
74
 
60
- function resolveSessionName(currentPath = process.cwd()) {
61
- return getDefaultSessionName(currentPath);
75
+ function sanitizeChildPath(pathValue, homeDir) {
76
+ const arg0Root = path.join(homeDir, ".codex", "tmp", "arg0");
77
+ const entries = String(pathValue || "")
78
+ .split(path.delimiter)
79
+ .filter(Boolean)
80
+ .filter((entry) => {
81
+ const resolved = path.resolve(entry);
82
+ return resolved !== arg0Root && !resolved.startsWith(`${arg0Root}${path.sep}`);
83
+ });
84
+
85
+ return entries.join(path.delimiter);
86
+ }
87
+
88
+ function buildChildEnv(seatId, sessionName, cwd, baseEnv = process.env) {
89
+ const env = { ...baseEnv };
90
+ for (const key of CHILD_ENV_DROP_KEYS) {
91
+ delete env[key];
92
+ }
93
+
94
+ const homeDir = String(env.HOME || "").trim() || process.env.HOME || "/root";
95
+ env.PATH = sanitizeChildPath(env.PATH, homeDir);
96
+ env.PWD = cwd;
97
+ env.TERM = resolveChildTerm(baseEnv);
98
+ env.MUUUUSE_SEAT = String(seatId);
99
+ env.MUUUUSE_SESSION = sessionName;
100
+ return env;
101
+ }
102
+
103
+ function normalizeWorkingPath(currentPath = process.cwd()) {
104
+ try {
105
+ return fs.realpathSync(currentPath);
106
+ } catch {
107
+ return path.resolve(currentPath);
108
+ }
109
+ }
110
+
111
+ function matchesWorkingPath(leftPath, rightPath) {
112
+ if (!leftPath || !rightPath) {
113
+ return false;
114
+ }
115
+
116
+ return normalizeWorkingPath(leftPath) === normalizeWorkingPath(rightPath);
117
+ }
118
+
119
+ function createSessionName(currentPath = process.cwd()) {
120
+ return `${getDefaultSessionName(currentPath)}-${createId(6)}`;
121
+ }
122
+
123
+ function sleepSync(ms) {
124
+ if (!Number.isFinite(ms) || ms <= 0) {
125
+ return;
126
+ }
127
+
128
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
129
+ }
130
+
131
+ function findJoinableSessionName(currentPath = process.cwd()) {
132
+ const candidates = listSessionNames()
133
+ .map((sessionName) => {
134
+ const sessionPaths = getSessionPaths(sessionName);
135
+ const controller = readJson(sessionPaths.controllerPath, null);
136
+ const seat1Paths = getSeatPaths(sessionName, 1);
137
+ const seat2Paths = getSeatPaths(sessionName, 2);
138
+ const seat1Meta = readJson(seat1Paths.metaPath, null);
139
+ const seat1Status = readJson(seat1Paths.statusPath, null);
140
+ const seat2Meta = readJson(seat2Paths.metaPath, null);
141
+ const seat2Status = readJson(seat2Paths.statusPath, null);
142
+ const stopRequest = readJson(sessionPaths.stopPath, null);
143
+
144
+ const cwd = controller?.cwd || seat1Status?.cwd || seat1Meta?.cwd || seat2Status?.cwd || seat2Meta?.cwd || null;
145
+ if (!matchesWorkingPath(cwd, currentPath)) {
146
+ return null;
147
+ }
148
+
149
+ const seat1WrapperPid = seat1Status?.pid || seat1Meta?.pid || null;
150
+ const seat1ChildPid = seat1Status?.childPid || seat1Meta?.childPid || null;
151
+ const seat2WrapperPid = seat2Status?.pid || seat2Meta?.pid || null;
152
+ const seat2ChildPid = seat2Status?.childPid || seat2Meta?.childPid || null;
153
+ const seat1Live = isPidAlive(seat1WrapperPid) || isPidAlive(seat1ChildPid);
154
+ const seat2Live = isPidAlive(seat2WrapperPid) || isPidAlive(seat2ChildPid);
155
+ const stopRequestedAtMs = Date.parse(stopRequest?.requestedAt || "");
156
+ const createdAtMs = Date.parse(controller?.createdAt || seat1Meta?.startedAt || seat1Status?.updatedAt || "");
157
+
158
+ if (!seat1Live || seat2Live) {
159
+ return null;
160
+ }
161
+
162
+ if (Number.isFinite(stopRequestedAtMs) && Number.isFinite(createdAtMs) && stopRequestedAtMs > createdAtMs) {
163
+ return null;
164
+ }
165
+
166
+ return {
167
+ sessionName,
168
+ createdAtMs,
169
+ };
170
+ })
171
+ .filter((entry) => entry !== null)
172
+ .sort((left, right) => right.createdAtMs - left.createdAtMs);
173
+
174
+ return candidates[0]?.sessionName || null;
175
+ }
176
+
177
+ function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEAT_JOIN_WAIT_MS) {
178
+ const deadline = Date.now() + timeoutMs;
179
+ while (Date.now() <= deadline) {
180
+ const sessionName = findJoinableSessionName(currentPath);
181
+ if (sessionName) {
182
+ return sessionName;
183
+ }
184
+ sleepSync(SEAT_JOIN_POLL_MS);
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ function resolveSessionName(currentPath = process.cwd(), seatId = 1) {
191
+ if (seatId === 1) {
192
+ return createSessionName(currentPath);
193
+ }
194
+
195
+ if (seatId === 2) {
196
+ return waitForJoinableSessionName(currentPath);
197
+ }
198
+
199
+ return createSessionName(currentPath);
62
200
  }
63
201
 
64
202
  function parseAnswerEntries(text) {
@@ -118,20 +256,25 @@ function getChildProcesses(rootPid) {
118
256
  .filter((entry) => entry !== null);
119
257
 
120
258
  const descendants = [];
121
- const queue = [rootPid];
122
- const seen = new Set(queue);
259
+ const queue = [{ pid: rootPid, depth: 0 }];
260
+ const seen = new Set([rootPid]);
123
261
 
124
262
  while (queue.length > 0) {
125
- const parentPid = queue.shift();
263
+ const current = queue.shift();
264
+ const parentPid = current.pid;
126
265
  for (const process of processes) {
127
266
  if (process.ppid !== parentPid || seen.has(process.pid)) {
128
267
  continue;
129
268
  }
130
269
  seen.add(process.pid);
131
- queue.push(process.pid);
270
+ queue.push({
271
+ pid: process.pid,
272
+ depth: current.depth + 1,
273
+ });
132
274
  descendants.push({
133
275
  ...process,
134
276
  cwd: readProcessCwd(process.pid),
277
+ depth: current.depth + 1,
135
278
  });
136
279
  }
137
280
  }
@@ -142,23 +285,107 @@ function getChildProcesses(rootPid) {
142
285
  }
143
286
  }
144
287
 
145
- function resolveSessionFile(agentType, currentPath, processStartedAtMs) {
288
+ function getProcessFamilyPids(processes, rootPid) {
289
+ if (!Number.isInteger(rootPid) || rootPid <= 0) {
290
+ return [];
291
+ }
292
+
293
+ const related = new Set([rootPid]);
294
+ const queue = [rootPid];
295
+
296
+ while (queue.length > 0) {
297
+ const parentPid = queue.shift();
298
+ for (const process of processes) {
299
+ if (process.ppid !== parentPid || related.has(process.pid)) {
300
+ continue;
301
+ }
302
+
303
+ related.add(process.pid);
304
+ queue.push(process.pid);
305
+ }
306
+ }
307
+
308
+ return [...related];
309
+ }
310
+
311
+ function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, processStartedAtMs, seatContext = {}) {
146
312
  if (!currentPath) {
147
313
  return null;
148
314
  }
149
315
 
316
+ const options = {
317
+ pid: agentPid,
318
+ pids: seatContext.agentPids,
319
+ captureSinceMs,
320
+ seatId: seatContext.seatId,
321
+ sessionName: seatContext.sessionName,
322
+ };
323
+
150
324
  if (agentType === "codex") {
151
- return selectCodexSessionFile(currentPath, processStartedAtMs);
325
+ return selectCodexSessionFile(currentPath, processStartedAtMs, options);
152
326
  }
153
327
  if (agentType === "claude") {
154
- return selectClaudeSessionFile(currentPath, processStartedAtMs);
328
+ return selectClaudeSessionFile(currentPath, processStartedAtMs, options);
155
329
  }
156
330
  if (agentType === "gemini") {
157
- return selectGeminiSessionFile(currentPath, processStartedAtMs);
331
+ return selectGeminiSessionFile(currentPath, processStartedAtMs, options);
158
332
  }
159
333
  return null;
160
334
  }
161
335
 
336
+ function buildClaimMessage(sessionName, challenge, seat1PublicKey, seat2PublicKey) {
337
+ return JSON.stringify({
338
+ type: "muuuuse_pair_claim",
339
+ sessionName,
340
+ challenge,
341
+ seat1PublicKey,
342
+ seat2PublicKey,
343
+ });
344
+ }
345
+
346
+ function buildAckMessage(sessionName, challenge, seat1PublicKey, seat2PublicKey) {
347
+ return JSON.stringify({
348
+ type: "muuuuse_pair_ack",
349
+ sessionName,
350
+ challenge,
351
+ seat1PublicKey,
352
+ seat2PublicKey,
353
+ });
354
+ }
355
+
356
+ function buildAnswerSignaturePayload(sessionName, challenge, entry) {
357
+ return JSON.stringify({
358
+ type: "muuuuse_answer",
359
+ sessionName,
360
+ challenge,
361
+ chainId: entry.chainId,
362
+ hop: entry.hop,
363
+ id: entry.id,
364
+ seatId: entry.seatId,
365
+ origin: entry.origin,
366
+ createdAt: entry.createdAt,
367
+ text: entry.text,
368
+ });
369
+ }
370
+
371
+ function readSeatChallenge(paths, sessionName) {
372
+ const record = readJson(paths.challengePath, null);
373
+ if (
374
+ !record ||
375
+ record.sessionName !== sessionName ||
376
+ typeof record.challenge !== "string" ||
377
+ typeof record.publicKey !== "string"
378
+ ) {
379
+ return null;
380
+ }
381
+
382
+ return {
383
+ challenge: record.challenge,
384
+ publicKey: record.publicKey.trim(),
385
+ createdAt: record.createdAt || null,
386
+ };
387
+ }
388
+
162
389
  async function sendTextAndEnter(child, text, shouldAbort = () => false) {
163
390
  const lines = String(text || "").replace(/\r/g, "").split("\n");
164
391
 
@@ -208,8 +435,11 @@ class ArmedSeat {
208
435
  constructor(options) {
209
436
  this.seatId = options.seatId;
210
437
  this.partnerSeatId = options.seatId === 1 ? 2 : 1;
211
- this.cwd = options.cwd;
212
- this.sessionName = resolveSessionName(this.cwd);
438
+ this.cwd = normalizeWorkingPath(options.cwd);
439
+ this.sessionName = resolveSessionName(this.cwd, this.seatId);
440
+ if (!this.sessionName) {
441
+ throw new Error("No armed `muuuuse 1` seat is waiting in this cwd. Run `muuuuse 1` first.");
442
+ }
213
443
  this.sessionPaths = getSessionPaths(this.sessionName);
214
444
  this.paths = getSeatPaths(this.sessionName, this.seatId);
215
445
  this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
@@ -226,7 +456,17 @@ class ArmedSeat {
226
456
  this.stdinCleanup = null;
227
457
  this.resizeCleanup = null;
228
458
  this.forceKillTimer = null;
459
+ this.identity = null;
460
+ this.lastUserInputAtMs = 0;
461
+ this.pendingInboundContext = null;
229
462
  this.recentInboundRelays = [];
463
+ this.recentEmittedAnswers = [];
464
+ this.trustState = {
465
+ challenge: null,
466
+ peerPublicKey: null,
467
+ phase: this.seatId === 1 ? "waiting_for_peer_signature" : "waiting_for_seat1_key",
468
+ pairedAt: null,
469
+ };
230
470
  this.liveState = {
231
471
  type: null,
232
472
  pid: null,
@@ -240,6 +480,20 @@ class ArmedSeat {
240
480
  };
241
481
  }
242
482
 
483
+ writeController(extra = {}) {
484
+ const current = readJson(this.sessionPaths.controllerPath, {});
485
+ writeJson(this.sessionPaths.controllerPath, {
486
+ sessionName: this.sessionName,
487
+ cwd: this.cwd,
488
+ createdAt: current.createdAt || this.startedAt,
489
+ updatedAt: new Date().toISOString(),
490
+ seat1Pid: this.seatId === 1 ? process.pid : current.seat1Pid || null,
491
+ seat2Pid: this.seatId === 2 ? process.pid : current.seat2Pid || null,
492
+ pid: this.seatId === 1 ? process.pid : current.pid || null,
493
+ ...extra,
494
+ });
495
+ }
496
+
243
497
  log(message) {
244
498
  process.stderr.write(`${message}\n`);
245
499
  }
@@ -272,29 +526,193 @@ class ArmedSeat {
272
526
  });
273
527
  }
274
528
 
529
+ initializeTrustMaterial() {
530
+ this.identity = loadOrCreateSeatIdentity(this.paths);
531
+
532
+ if (this.seatId !== 1) {
533
+ return;
534
+ }
535
+
536
+ writeJson(this.paths.challengePath, {
537
+ sessionName: this.sessionName,
538
+ challenge: createId(48),
539
+ publicKey: this.identity.publicKey,
540
+ createdAt: new Date().toISOString(),
541
+ });
542
+ this.trustState.challenge = readSeatChallenge(this.paths, this.sessionName)?.challenge || null;
543
+ this.trustState.peerPublicKey = null;
544
+ this.trustState.phase = "waiting_for_peer_signature";
545
+ this.trustState.pairedAt = null;
546
+ fs.rmSync(this.paths.ackPath, { force: true });
547
+ fs.rmSync(this.partnerPaths.claimPath, { force: true });
548
+ }
549
+
550
+ syncTrustState() {
551
+ if (!this.identity) {
552
+ this.initializeTrustMaterial();
553
+ }
554
+
555
+ if (this.seatId === 1) {
556
+ this.syncSeatOneTrust();
557
+ return;
558
+ }
559
+
560
+ this.syncSeatTwoTrust();
561
+ }
562
+
563
+ syncSeatOneTrust() {
564
+ const challengeRecord = readSeatChallenge(this.paths, this.sessionName);
565
+ if (!challengeRecord || challengeRecord.publicKey !== this.identity.publicKey) {
566
+ this.trustState = {
567
+ challenge: null,
568
+ peerPublicKey: null,
569
+ phase: "waiting_for_peer_signature",
570
+ pairedAt: null,
571
+ };
572
+ return;
573
+ }
574
+
575
+ this.trustState.challenge = challengeRecord.challenge;
576
+ const claim = readJson(this.partnerPaths.claimPath, null);
577
+ if (
578
+ !claim ||
579
+ claim.sessionName !== this.sessionName ||
580
+ claim.challenge !== challengeRecord.challenge ||
581
+ typeof claim.publicKey !== "string" ||
582
+ typeof claim.signature !== "string" ||
583
+ !verifyText(
584
+ buildClaimMessage(
585
+ this.sessionName,
586
+ challengeRecord.challenge,
587
+ this.identity.publicKey,
588
+ claim.publicKey.trim()
589
+ ),
590
+ claim.signature,
591
+ claim.publicKey
592
+ )
593
+ ) {
594
+ this.trustState.peerPublicKey = null;
595
+ this.trustState.phase = "waiting_for_peer_signature";
596
+ this.trustState.pairedAt = null;
597
+ fs.rmSync(this.paths.ackPath, { force: true });
598
+ return;
599
+ }
600
+
601
+ const peerPublicKey = claim.publicKey.trim();
602
+ const ackMessage = buildAckMessage(this.sessionName, challengeRecord.challenge, this.identity.publicKey, peerPublicKey);
603
+ const currentAck = readJson(this.paths.ackPath, null);
604
+ const ackIsValid = Boolean(
605
+ currentAck &&
606
+ currentAck.sessionName === this.sessionName &&
607
+ currentAck.challenge === challengeRecord.challenge &&
608
+ currentAck.publicKey === this.identity.publicKey &&
609
+ currentAck.peerPublicKey === peerPublicKey &&
610
+ typeof currentAck.signature === "string" &&
611
+ verifyText(ackMessage, currentAck.signature, this.identity.publicKey)
612
+ );
613
+ if (!ackIsValid) {
614
+ writeJson(this.paths.ackPath, {
615
+ sessionName: this.sessionName,
616
+ challenge: challengeRecord.challenge,
617
+ publicKey: this.identity.publicKey,
618
+ peerPublicKey,
619
+ signature: signText(ackMessage, this.identity.privateKey),
620
+ signedAt: new Date().toISOString(),
621
+ });
622
+ }
623
+
624
+ const ackRecord = ackIsValid ? currentAck : readJson(this.paths.ackPath, null);
625
+ this.trustState.peerPublicKey = peerPublicKey;
626
+ this.trustState.phase = "paired";
627
+ this.trustState.pairedAt = ackRecord?.signedAt || new Date().toISOString();
628
+ }
629
+
630
+ syncSeatTwoTrust() {
631
+ const challengeRecord = readSeatChallenge(this.partnerPaths, this.sessionName);
632
+ if (!challengeRecord) {
633
+ this.trustState = {
634
+ challenge: null,
635
+ peerPublicKey: null,
636
+ phase: "waiting_for_seat1_key",
637
+ pairedAt: null,
638
+ };
639
+ return;
640
+ }
641
+
642
+ const challenge = challengeRecord.challenge;
643
+ const peerPublicKey = challengeRecord.publicKey;
644
+ const claimPayload = {
645
+ sessionName: this.sessionName,
646
+ challenge,
647
+ publicKey: this.identity.publicKey,
648
+ };
649
+ const claimSignature = signText(
650
+ buildClaimMessage(this.sessionName, challenge, peerPublicKey, this.identity.publicKey),
651
+ this.identity.privateKey
652
+ );
653
+ const currentClaim = readJson(this.paths.claimPath, null);
654
+ if (
655
+ !currentClaim ||
656
+ currentClaim.sessionName !== claimPayload.sessionName ||
657
+ currentClaim.challenge !== claimPayload.challenge ||
658
+ currentClaim.publicKey !== claimPayload.publicKey ||
659
+ currentClaim.signature !== claimSignature
660
+ ) {
661
+ writeJson(this.paths.claimPath, {
662
+ ...claimPayload,
663
+ signature: claimSignature,
664
+ signedAt: new Date().toISOString(),
665
+ });
666
+ }
667
+
668
+ const ack = readJson(this.partnerPaths.ackPath, null);
669
+ const paired = Boolean(
670
+ ack &&
671
+ ack.sessionName === this.sessionName &&
672
+ ack.challenge === challenge &&
673
+ ack.peerPublicKey === this.identity.publicKey &&
674
+ ack.publicKey === peerPublicKey &&
675
+ typeof ack.signature === "string" &&
676
+ verifyText(
677
+ buildAckMessage(this.sessionName, challenge, peerPublicKey, this.identity.publicKey),
678
+ ack.signature,
679
+ peerPublicKey
680
+ )
681
+ );
682
+
683
+ this.trustState.challenge = challenge;
684
+ this.trustState.peerPublicKey = peerPublicKey;
685
+ this.trustState.phase = paired ? "paired" : "waiting_for_pair_ack";
686
+ this.trustState.pairedAt = paired ? (ack.signedAt || new Date().toISOString()) : null;
687
+ }
688
+
689
+ isPaired() {
690
+ return this.trustState.phase === "paired" &&
691
+ typeof this.trustState.challenge === "string" &&
692
+ typeof this.trustState.peerPublicKey === "string";
693
+ }
694
+
275
695
  launchShell() {
276
696
  ensureDir(this.paths.dir);
277
697
  fs.rmSync(this.paths.pipePath, { force: true });
278
698
  clearStaleStopRequest(this.sessionPaths.stopPath, this.startedAtMs);
699
+ this.initializeTrustMaterial();
700
+ this.writeController();
279
701
 
280
702
  const shell = resolveShell();
281
703
  const shellArgs = resolveShellArgs(shell);
704
+ const childEnv = buildChildEnv(this.seatId, this.sessionName, this.cwd);
282
705
  this.child = pty.spawn(shell, shellArgs, {
283
706
  cols: process.stdout.columns || 120,
284
707
  rows: process.stdout.rows || 36,
285
708
  cwd: this.cwd,
286
- env: {
287
- ...process.env,
288
- TERM: resolveChildTerm(),
289
- MUUUUSE_SEAT: String(this.seatId),
290
- MUUUUSE_SESSION: this.sessionName,
291
- },
292
- name: resolveChildTerm(),
709
+ env: childEnv,
710
+ name: childEnv.TERM,
293
711
  });
294
712
 
295
713
  this.childPid = this.child.pid;
296
714
  this.writeMeta();
297
- this.writeStatus({ state: "running" });
715
+ this.writeStatus({ state: "running", trust: this.trustState.phase });
298
716
 
299
717
  this.child.onData((data) => {
300
718
  fs.appendFileSync(this.paths.pipePath, data);
@@ -314,6 +732,8 @@ class ArmedSeat {
314
732
 
315
733
  installStdinProxy() {
316
734
  const handleData = (chunk) => {
735
+ this.lastUserInputAtMs = Date.now();
736
+ this.pendingInboundContext = null;
317
737
  if (!this.child) {
318
738
  return;
319
739
  }
@@ -441,7 +861,7 @@ class ArmedSeat {
441
861
  async pullPartnerEvents() {
442
862
  const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
443
863
  this.partnerOffset = nextOffset;
444
- if (!text.trim() || !this.child || this.stopped) {
864
+ if (!text.trim() || !this.child || this.stopped || !this.isPaired()) {
445
865
  return;
446
866
  }
447
867
 
@@ -453,7 +873,22 @@ class ArmedSeat {
453
873
  }
454
874
 
455
875
  const payload = sanitizeRelayText(entry.text);
456
- if (!payload) {
876
+ const signaturePayload = buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, {
877
+ chainId: entry.chainId || entry.id,
878
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
879
+ id: entry.id,
880
+ seatId: entry.seatId,
881
+ origin: entry.origin || "unknown",
882
+ createdAt: entry.createdAt,
883
+ text: payload,
884
+ });
885
+ if (
886
+ !payload ||
887
+ entry.challenge !== this.trustState.challenge ||
888
+ entry.publicKey !== this.trustState.peerPublicKey ||
889
+ typeof entry.signature !== "string" ||
890
+ !verifyText(signaturePayload, entry.signature, this.trustState.peerPublicKey)
891
+ ) {
457
892
  continue;
458
893
  }
459
894
 
@@ -472,6 +907,14 @@ class ArmedSeat {
472
907
  return;
473
908
  }
474
909
 
910
+ const deliveredAtMs = Date.now();
911
+ this.pendingInboundContext = {
912
+ chainId: entry.chainId || entry.id,
913
+ deliveredAtMs,
914
+ expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
915
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
916
+ relayUsed: false,
917
+ };
475
918
  this.relayCount += 1;
476
919
  this.rememberInboundRelay(payload);
477
920
  this.log(`[${this.partnerSeatId} -> ${this.seatId}] ${previewText(payload)}`);
@@ -503,6 +946,37 @@ class ArmedSeat {
503
946
  );
504
947
  }
505
948
 
949
+ pruneRecentEmittedAnswers(now = Date.now()) {
950
+ this.recentEmittedAnswers = this.recentEmittedAnswers.filter(
951
+ (entry) => now - entry.timestampMs <= EMITTED_ANSWER_TTL_MS
952
+ );
953
+ }
954
+
955
+ hasRecentEmittedAnswer(answerKey) {
956
+ if (!answerKey) {
957
+ return false;
958
+ }
959
+
960
+ this.pruneRecentEmittedAnswers();
961
+ return this.recentEmittedAnswers.some((entry) => entry.key === answerKey);
962
+ }
963
+
964
+ rememberEmittedAnswer(answerKey) {
965
+ if (!answerKey) {
966
+ return;
967
+ }
968
+
969
+ this.pruneRecentEmittedAnswers();
970
+ this.recentEmittedAnswers.push({
971
+ key: answerKey,
972
+ timestampMs: Date.now(),
973
+ });
974
+
975
+ if (this.recentEmittedAnswers.length > MAX_RECENT_EMITTED_ANSWERS) {
976
+ this.recentEmittedAnswers = this.recentEmittedAnswers.slice(-MAX_RECENT_EMITTED_ANSWERS);
977
+ }
978
+ }
979
+
506
980
  takeMirroredInboundRelay(payload) {
507
981
  const normalized = sanitizeRelayText(payload);
508
982
  if (!normalized) {
@@ -520,8 +994,28 @@ class ArmedSeat {
520
994
  return match;
521
995
  }
522
996
 
997
+ getPendingInboundContext() {
998
+ const context = this.pendingInboundContext;
999
+ if (!context) {
1000
+ return null;
1001
+ }
1002
+
1003
+ if (context.expiresAtMs <= Date.now()) {
1004
+ this.pendingInboundContext = null;
1005
+ return null;
1006
+ }
1007
+
1008
+ if (this.lastUserInputAtMs > context.deliveredAtMs) {
1009
+ this.pendingInboundContext = null;
1010
+ return null;
1011
+ }
1012
+
1013
+ return context;
1014
+ }
1015
+
523
1016
  collectLiveAnswers() {
524
- const detectedAgent = detectAgent(getChildProcesses(this.childPid));
1017
+ const childProcesses = getChildProcesses(this.childPid);
1018
+ const detectedAgent = detectAgent(childProcesses);
525
1019
  if (!detectedAgent) {
526
1020
  this.liveState = {
527
1021
  type: null,
@@ -567,17 +1061,24 @@ class ArmedSeat {
567
1061
  };
568
1062
  }
569
1063
 
570
- if (!this.liveState.sessionFile) {
571
- this.liveState.sessionFile = resolveSessionFile(
572
- detectedAgent.type,
573
- currentPath,
574
- detectedAgent.processStartedAtMs
575
- );
576
-
577
- if (this.liveState.sessionFile) {
578
- this.liveState.offset = 0;
579
- this.liveState.lastMessageId = null;
1064
+ const agentPids = getProcessFamilyPids(childProcesses, detectedAgent.pid);
1065
+ const resolvedSessionFile = resolveSessionFile(
1066
+ detectedAgent.type,
1067
+ detectedAgent.pid,
1068
+ currentPath,
1069
+ this.liveState.captureSinceMs,
1070
+ detectedAgent.processStartedAtMs,
1071
+ {
1072
+ agentPids,
1073
+ seatId: this.seatId,
1074
+ sessionName: this.sessionName,
580
1075
  }
1076
+ );
1077
+
1078
+ if (resolvedSessionFile && resolvedSessionFile !== this.liveState.sessionFile) {
1079
+ this.liveState.sessionFile = resolvedSessionFile;
1080
+ this.liveState.offset = 0;
1081
+ this.liveState.lastMessageId = null;
581
1082
  }
582
1083
 
583
1084
  if (!this.liveState.sessionFile) {
@@ -643,7 +1144,13 @@ class ArmedSeat {
643
1144
  }
644
1145
 
645
1146
  const payload = sanitizeRelayText(entry.text);
646
- if (!payload) {
1147
+ if (!payload || !this.identity || !this.trustState.challenge) {
1148
+ return;
1149
+ }
1150
+
1151
+ const answerKey = buildAnswerKey(entry, payload);
1152
+ if (this.hasRecentEmittedAnswer(answerKey)) {
1153
+ this.log(`[${this.seatId}] suppressed duplicate final answer: ${previewText(payload)}`);
647
1154
  return;
648
1155
  }
649
1156
 
@@ -653,14 +1160,39 @@ class ArmedSeat {
653
1160
  return;
654
1161
  }
655
1162
 
656
- appendJsonl(this.paths.eventsPath, {
657
- id: entry.id || createId(12),
1163
+ const pendingInboundContext = this.getPendingInboundContext();
1164
+ if (pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1165
+ this.log(`[${this.seatId}] suppressed relay loop: ${previewText(payload)}`);
1166
+ return;
1167
+ }
1168
+
1169
+ if (pendingInboundContext?.relayUsed) {
1170
+ this.log(`[${this.seatId}] suppressed extra queued relay output: ${previewText(payload)}`);
1171
+ return;
1172
+ }
1173
+
1174
+ const entryId = entry.id || createId(12);
1175
+ const signedEntry = {
1176
+ id: entryId,
658
1177
  type: "answer",
659
1178
  seatId: this.seatId,
660
1179
  origin: entry.origin || "unknown",
661
1180
  text: payload,
662
1181
  createdAt: entry.createdAt || new Date().toISOString(),
663
- });
1182
+ chainId: pendingInboundContext?.chainId || entry.chainId || entryId,
1183
+ hop: pendingInboundContext ? pendingInboundContext.hop + 1 : 0,
1184
+ challenge: this.trustState.challenge,
1185
+ publicKey: this.identity.publicKey,
1186
+ };
1187
+ signedEntry.signature = signText(
1188
+ buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, signedEntry),
1189
+ this.identity.privateKey
1190
+ );
1191
+ appendJsonl(this.paths.eventsPath, signedEntry);
1192
+ this.rememberEmittedAnswer(answerKey);
1193
+ if (pendingInboundContext) {
1194
+ pendingInboundContext.relayUsed = true;
1195
+ }
664
1196
 
665
1197
  this.log(`[${this.seatId}] ${previewText(payload)}`);
666
1198
  }
@@ -670,11 +1202,13 @@ class ArmedSeat {
670
1202
  this.writeStatus({
671
1203
  state: "stopping",
672
1204
  partnerLive: this.partnerIsLive(),
1205
+ trust: this.trustState.phase,
673
1206
  });
674
1207
  this.requestStop("stop_requested");
675
1208
  return;
676
1209
  }
677
1210
 
1211
+ this.syncTrustState();
678
1212
  await this.pullPartnerEvents();
679
1213
  if (this.stopped || this.stopRequested()) {
680
1214
  this.requestStop("stop_requested");
@@ -693,6 +1227,8 @@ class ArmedSeat {
693
1227
  log: live.log,
694
1228
  lastAnswerAt: live.lastAnswerAt,
695
1229
  partnerLive: this.partnerIsLive(),
1230
+ trust: this.trustState.phase,
1231
+ challengeReady: Boolean(this.trustState.challenge),
696
1232
  });
697
1233
  }
698
1234
 
@@ -704,6 +1240,11 @@ class ArmedSeat {
704
1240
 
705
1241
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
706
1242
  this.log("Use this shell normally. Codex, Claude, and Gemini final answers relay automatically from their local session logs.");
1243
+ if (this.seatId === 1) {
1244
+ this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
1245
+ } else {
1246
+ this.log("Seat 2 will sign the session key from seat 1, then relay goes live.");
1247
+ }
707
1248
  this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
708
1249
 
709
1250
  try {
@@ -764,6 +1305,17 @@ function previewText(text, maxLength = 88) {
764
1305
  return `${compact.slice(0, maxLength - 3)}...`;
765
1306
  }
766
1307
 
1308
+ function buildAnswerKey(entry, payload) {
1309
+ const origin = String(entry.origin || "unknown").trim() || "unknown";
1310
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
1311
+ if (id) {
1312
+ return `${origin}:${id}`;
1313
+ }
1314
+
1315
+ const createdAt = typeof entry.createdAt === "string" ? entry.createdAt : "";
1316
+ return `${origin}:${createdAt}:${hashText(payload)}`;
1317
+ }
1318
+
767
1319
  function buildSeatReport(sessionName, seatId) {
768
1320
  const paths = getSeatPaths(sessionName, seatId);
769
1321
  const daemon = readJson(paths.daemonPath, null);
@@ -797,6 +1349,7 @@ function buildSeatReport(sessionName, seatId) {
797
1349
  relayCount: status?.relayCount || 0,
798
1350
  log: status?.log || null,
799
1351
  startedAt: meta?.startedAt || null,
1352
+ trust: status?.trust || null,
800
1353
  updatedAt: status?.updatedAt || null,
801
1354
  lastAnswerAt: status?.lastAnswerAt || null,
802
1355
  partnerLive: Boolean(status?.partnerLive),
@@ -871,6 +1424,7 @@ function stopAllSessions() {
871
1424
 
872
1425
  module.exports = {
873
1426
  ArmedSeat,
1427
+ buildChildEnv,
874
1428
  getStatusReport,
875
1429
  resolveSessionName,
876
1430
  stopAllSessions,