muuuuse 1.4.2 → 1.5.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
@@ -5,6 +5,7 @@
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
8
9
  - watch Codex, Claude, or Gemini for real final answers
9
10
  - inject that final answer into the other armed terminal
10
11
  - keep looping until you stop it
@@ -32,7 +33,7 @@ Terminal 2:
32
33
  muuuuse 2
33
34
  ```
34
35
 
35
- Now both shells are armed. Use them normally.
36
+ 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.
36
37
 
37
38
  If you want Codex in one and Gemini in the other, start them inside the armed shells:
38
39
 
@@ -62,6 +63,7 @@ muuuuse stop
62
63
 
63
64
  - no tmux
64
65
  - state lives under `~/.muuuuse`
66
+ - only the signed armed pair can exchange relay events
65
67
  - supported final-answer detection is built for Codex, Claude, and Gemini
66
68
  - `codeman` remains the larger transport/control layer; `muuuuse` stays local and minimal
67
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muuuuse",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "🔌Muuuuse arms two regular terminals and relays final answers between them.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -128,6 +128,57 @@ function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs)
128
128
  return null;
129
129
  }
130
130
 
131
+ function listOpenFilePaths(pid, rootPath) {
132
+ if (!Number.isInteger(pid) || pid <= 0) {
133
+ return [];
134
+ }
135
+
136
+ const fdRoot = `/proc/${pid}/fd`;
137
+ try {
138
+ const rootPrefix = path.resolve(rootPath);
139
+ const openPaths = fs.readdirSync(fdRoot)
140
+ .map((entry) => {
141
+ try {
142
+ return fs.realpathSync(path.join(fdRoot, entry));
143
+ } catch {
144
+ return null;
145
+ }
146
+ })
147
+ .filter((entry) => typeof entry === "string")
148
+ .filter((entry) => entry.startsWith(rootPrefix));
149
+
150
+ return [...new Set(openPaths)];
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ function selectLiveSessionCandidatePath(candidates, currentPath, captureSinceMs = null) {
157
+ const cwdMatches = candidates.filter((candidate) => candidate.cwd === currentPath);
158
+ if (cwdMatches.length === 0) {
159
+ return null;
160
+ }
161
+
162
+ const primary = cwdMatches.some((candidate) => candidate.isSubagent === false)
163
+ ? cwdMatches.filter((candidate) => candidate.isSubagent === false)
164
+ : cwdMatches;
165
+
166
+ const recent = Number.isFinite(captureSinceMs)
167
+ ? primary.filter((candidate) => Number.isFinite(candidate.mtimeMs) && candidate.mtimeMs >= captureSinceMs - SESSION_START_EARLY_TOLERANCE_MS)
168
+ : primary;
169
+ const ranked = (recent.length > 0 ? recent : primary)
170
+ .slice()
171
+ .sort((left, right) => right.mtimeMs - left.mtimeMs || right.startedAtMs - left.startedAtMs);
172
+
173
+ return ranked[0]?.path || null;
174
+ }
175
+
176
+ function readOpenSessionCandidates(pid, rootPath, reader) {
177
+ return listOpenFilePaths(pid, rootPath)
178
+ .map((filePath) => reader(filePath))
179
+ .filter((candidate) => candidate !== null);
180
+ }
181
+
131
182
  function readCodexCandidate(filePath) {
132
183
  try {
133
184
  const [firstLine] = readFirstLines(filePath, 1);
@@ -143,6 +194,7 @@ function readCodexCandidate(filePath) {
143
194
  return {
144
195
  path: filePath,
145
196
  cwd: entry.payload.cwd,
197
+ isSubagent: Boolean(entry.payload?.source?.subagent),
146
198
  startedAtMs: Date.parse(entry.payload.timestamp),
147
199
  mtimeMs: fs.statSync(filePath).mtimeMs,
148
200
  };
@@ -151,7 +203,13 @@ function readCodexCandidate(filePath) {
151
203
  }
152
204
  }
153
205
 
154
- function selectCodexSessionFile(currentPath, processStartedAtMs) {
206
+ function selectCodexSessionFile(currentPath, processStartedAtMs, options = {}) {
207
+ const liveCandidates = readOpenSessionCandidates(options.pid, CODEX_ROOT, readCodexCandidate);
208
+ const livePath = selectLiveSessionCandidatePath(liveCandidates, currentPath, options.captureSinceMs);
209
+ if (livePath) {
210
+ return livePath;
211
+ }
212
+
155
213
  const candidates = walkFiles(CODEX_ROOT, (filePath) => filePath.endsWith(".jsonl"))
156
214
  .map((filePath) => readCodexCandidate(filePath))
157
215
  .filter((candidate) => candidate !== null);
@@ -252,7 +310,13 @@ function readClaudeCandidate(filePath) {
252
310
  }
253
311
  }
254
312
 
255
- function selectClaudeSessionFile(currentPath, processStartedAtMs) {
313
+ function selectClaudeSessionFile(currentPath, processStartedAtMs, options = {}) {
314
+ const liveCandidates = readOpenSessionCandidates(options.pid, CLAUDE_ROOT, readClaudeCandidate);
315
+ const livePath = selectLiveSessionCandidatePath(liveCandidates, currentPath, options.captureSinceMs);
316
+ if (livePath) {
317
+ return livePath;
318
+ }
319
+
256
320
  const candidates = walkFiles(CLAUDE_ROOT, (filePath) => filePath.endsWith(".jsonl"))
257
321
  .map((filePath) => readClaudeCandidate(filePath))
258
322
  .filter((candidate) => candidate !== null);
@@ -331,8 +395,15 @@ function readGeminiCandidate(filePath) {
331
395
  }
332
396
  }
333
397
 
334
- function selectGeminiSessionFile(currentPath, processStartedAtMs) {
398
+ function selectGeminiSessionFile(currentPath, processStartedAtMs, options = {}) {
335
399
  const projectHash = createHash("sha256").update(currentPath).digest("hex");
400
+ const liveCandidates = readOpenSessionCandidates(options.pid, GEMINI_ROOT, readGeminiCandidate)
401
+ .filter((candidate) => candidate.projectHash === projectHash);
402
+ const livePath = selectLiveSessionCandidatePath(liveCandidates, projectHash, options.captureSinceMs);
403
+ if (livePath) {
404
+ return livePath;
405
+ }
406
+
336
407
  const candidates = walkFiles(GEMINI_ROOT, (filePath) => filePath.endsWith(".json"))
337
408
  .map((filePath) => readGeminiCandidate(filePath))
338
409
  .filter((candidate) => candidate !== null && candidate.projectHash === projectHash);
@@ -384,6 +455,7 @@ module.exports = {
384
455
  readClaudeAnswers,
385
456
  readCodexAnswers,
386
457
  readGeminiAnswers,
458
+ selectLiveSessionCandidatePath,
387
459
  selectSessionCandidatePath,
388
460
  selectClaudeSessionFile,
389
461
  selectCodexSessionFile,
package/src/cli.js CHANGED
@@ -82,6 +82,9 @@ function renderSeatStatus(seat) {
82
82
  if (seat.partnerLive) {
83
83
  bits.push("peer live");
84
84
  }
85
+ if (seat.trust) {
86
+ bits.push(`trust ${seat.trust}`);
87
+ }
85
88
  if (seat.lastAnswerAt) {
86
89
  bits.push(`last answer ${seat.lastAnswerAt}`);
87
90
  }
package/src/runtime.js CHANGED
@@ -23,11 +23,14 @@ const {
23
23
  getSessionPaths,
24
24
  hashText,
25
25
  isPidAlive,
26
+ loadOrCreateSeatIdentity,
26
27
  listSessionNames,
27
28
  readAppendedText,
28
29
  readJson,
29
30
  sanitizeRelayText,
31
+ signText,
30
32
  sleep,
33
+ verifyText,
31
34
  writeJson,
32
35
  } = require("./util");
33
36
 
@@ -142,23 +145,79 @@ function getChildProcesses(rootPid) {
142
145
  }
143
146
  }
144
147
 
145
- function resolveSessionFile(agentType, currentPath, processStartedAtMs) {
148
+ function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, processStartedAtMs) {
146
149
  if (!currentPath) {
147
150
  return null;
148
151
  }
149
152
 
153
+ const options = {
154
+ pid: agentPid,
155
+ captureSinceMs,
156
+ };
157
+
150
158
  if (agentType === "codex") {
151
- return selectCodexSessionFile(currentPath, processStartedAtMs);
159
+ return selectCodexSessionFile(currentPath, processStartedAtMs, options);
152
160
  }
153
161
  if (agentType === "claude") {
154
- return selectClaudeSessionFile(currentPath, processStartedAtMs);
162
+ return selectClaudeSessionFile(currentPath, processStartedAtMs, options);
155
163
  }
156
164
  if (agentType === "gemini") {
157
- return selectGeminiSessionFile(currentPath, processStartedAtMs);
165
+ return selectGeminiSessionFile(currentPath, processStartedAtMs, options);
158
166
  }
159
167
  return null;
160
168
  }
161
169
 
170
+ function buildClaimMessage(sessionName, challenge, seat1PublicKey, seat2PublicKey) {
171
+ return JSON.stringify({
172
+ type: "muuuuse_pair_claim",
173
+ sessionName,
174
+ challenge,
175
+ seat1PublicKey,
176
+ seat2PublicKey,
177
+ });
178
+ }
179
+
180
+ function buildAckMessage(sessionName, challenge, seat1PublicKey, seat2PublicKey) {
181
+ return JSON.stringify({
182
+ type: "muuuuse_pair_ack",
183
+ sessionName,
184
+ challenge,
185
+ seat1PublicKey,
186
+ seat2PublicKey,
187
+ });
188
+ }
189
+
190
+ function buildAnswerSignaturePayload(sessionName, challenge, entry) {
191
+ return JSON.stringify({
192
+ type: "muuuuse_answer",
193
+ sessionName,
194
+ challenge,
195
+ id: entry.id,
196
+ seatId: entry.seatId,
197
+ origin: entry.origin,
198
+ createdAt: entry.createdAt,
199
+ text: entry.text,
200
+ });
201
+ }
202
+
203
+ function readSeatChallenge(paths, sessionName) {
204
+ const record = readJson(paths.challengePath, null);
205
+ if (
206
+ !record ||
207
+ record.sessionName !== sessionName ||
208
+ typeof record.challenge !== "string" ||
209
+ typeof record.publicKey !== "string"
210
+ ) {
211
+ return null;
212
+ }
213
+
214
+ return {
215
+ challenge: record.challenge,
216
+ publicKey: record.publicKey.trim(),
217
+ createdAt: record.createdAt || null,
218
+ };
219
+ }
220
+
162
221
  async function sendTextAndEnter(child, text, shouldAbort = () => false) {
163
222
  const lines = String(text || "").replace(/\r/g, "").split("\n");
164
223
 
@@ -226,7 +285,14 @@ class ArmedSeat {
226
285
  this.stdinCleanup = null;
227
286
  this.resizeCleanup = null;
228
287
  this.forceKillTimer = null;
288
+ this.identity = null;
229
289
  this.recentInboundRelays = [];
290
+ this.trustState = {
291
+ challenge: null,
292
+ peerPublicKey: null,
293
+ phase: this.seatId === 1 ? "waiting_for_peer_signature" : "waiting_for_seat1_key",
294
+ pairedAt: null,
295
+ };
230
296
  this.liveState = {
231
297
  type: null,
232
298
  pid: null,
@@ -272,10 +338,177 @@ class ArmedSeat {
272
338
  });
273
339
  }
274
340
 
341
+ initializeTrustMaterial() {
342
+ this.identity = loadOrCreateSeatIdentity(this.paths);
343
+
344
+ if (this.seatId !== 1) {
345
+ return;
346
+ }
347
+
348
+ writeJson(this.paths.challengePath, {
349
+ sessionName: this.sessionName,
350
+ challenge: createId(48),
351
+ publicKey: this.identity.publicKey,
352
+ createdAt: new Date().toISOString(),
353
+ });
354
+ this.trustState.challenge = readSeatChallenge(this.paths, this.sessionName)?.challenge || null;
355
+ this.trustState.peerPublicKey = null;
356
+ this.trustState.phase = "waiting_for_peer_signature";
357
+ this.trustState.pairedAt = null;
358
+ fs.rmSync(this.paths.ackPath, { force: true });
359
+ fs.rmSync(this.partnerPaths.claimPath, { force: true });
360
+ }
361
+
362
+ syncTrustState() {
363
+ if (!this.identity) {
364
+ this.initializeTrustMaterial();
365
+ }
366
+
367
+ if (this.seatId === 1) {
368
+ this.syncSeatOneTrust();
369
+ return;
370
+ }
371
+
372
+ this.syncSeatTwoTrust();
373
+ }
374
+
375
+ syncSeatOneTrust() {
376
+ const challengeRecord = readSeatChallenge(this.paths, this.sessionName);
377
+ if (!challengeRecord || challengeRecord.publicKey !== this.identity.publicKey) {
378
+ this.trustState = {
379
+ challenge: null,
380
+ peerPublicKey: null,
381
+ phase: "waiting_for_peer_signature",
382
+ pairedAt: null,
383
+ };
384
+ return;
385
+ }
386
+
387
+ this.trustState.challenge = challengeRecord.challenge;
388
+ const claim = readJson(this.partnerPaths.claimPath, null);
389
+ if (
390
+ !claim ||
391
+ claim.sessionName !== this.sessionName ||
392
+ claim.challenge !== challengeRecord.challenge ||
393
+ typeof claim.publicKey !== "string" ||
394
+ typeof claim.signature !== "string" ||
395
+ !verifyText(
396
+ buildClaimMessage(
397
+ this.sessionName,
398
+ challengeRecord.challenge,
399
+ this.identity.publicKey,
400
+ claim.publicKey.trim()
401
+ ),
402
+ claim.signature,
403
+ claim.publicKey
404
+ )
405
+ ) {
406
+ this.trustState.peerPublicKey = null;
407
+ this.trustState.phase = "waiting_for_peer_signature";
408
+ this.trustState.pairedAt = null;
409
+ fs.rmSync(this.paths.ackPath, { force: true });
410
+ return;
411
+ }
412
+
413
+ const peerPublicKey = claim.publicKey.trim();
414
+ const ackMessage = buildAckMessage(this.sessionName, challengeRecord.challenge, this.identity.publicKey, peerPublicKey);
415
+ const currentAck = readJson(this.paths.ackPath, null);
416
+ const ackIsValid = Boolean(
417
+ currentAck &&
418
+ currentAck.sessionName === this.sessionName &&
419
+ currentAck.challenge === challengeRecord.challenge &&
420
+ currentAck.publicKey === this.identity.publicKey &&
421
+ currentAck.peerPublicKey === peerPublicKey &&
422
+ typeof currentAck.signature === "string" &&
423
+ verifyText(ackMessage, currentAck.signature, this.identity.publicKey)
424
+ );
425
+ if (!ackIsValid) {
426
+ writeJson(this.paths.ackPath, {
427
+ sessionName: this.sessionName,
428
+ challenge: challengeRecord.challenge,
429
+ publicKey: this.identity.publicKey,
430
+ peerPublicKey,
431
+ signature: signText(ackMessage, this.identity.privateKey),
432
+ signedAt: new Date().toISOString(),
433
+ });
434
+ }
435
+
436
+ const ackRecord = ackIsValid ? currentAck : readJson(this.paths.ackPath, null);
437
+ this.trustState.peerPublicKey = peerPublicKey;
438
+ this.trustState.phase = "paired";
439
+ this.trustState.pairedAt = ackRecord?.signedAt || new Date().toISOString();
440
+ }
441
+
442
+ syncSeatTwoTrust() {
443
+ const challengeRecord = readSeatChallenge(this.partnerPaths, this.sessionName);
444
+ if (!challengeRecord) {
445
+ this.trustState = {
446
+ challenge: null,
447
+ peerPublicKey: null,
448
+ phase: "waiting_for_seat1_key",
449
+ pairedAt: null,
450
+ };
451
+ return;
452
+ }
453
+
454
+ const challenge = challengeRecord.challenge;
455
+ const peerPublicKey = challengeRecord.publicKey;
456
+ const claimPayload = {
457
+ sessionName: this.sessionName,
458
+ challenge,
459
+ publicKey: this.identity.publicKey,
460
+ };
461
+ const claimSignature = signText(
462
+ buildClaimMessage(this.sessionName, challenge, peerPublicKey, this.identity.publicKey),
463
+ this.identity.privateKey
464
+ );
465
+ const currentClaim = readJson(this.paths.claimPath, null);
466
+ if (
467
+ !currentClaim ||
468
+ currentClaim.sessionName !== claimPayload.sessionName ||
469
+ currentClaim.challenge !== claimPayload.challenge ||
470
+ currentClaim.publicKey !== claimPayload.publicKey ||
471
+ currentClaim.signature !== claimSignature
472
+ ) {
473
+ writeJson(this.paths.claimPath, {
474
+ ...claimPayload,
475
+ signature: claimSignature,
476
+ signedAt: new Date().toISOString(),
477
+ });
478
+ }
479
+
480
+ const ack = readJson(this.partnerPaths.ackPath, null);
481
+ const paired = Boolean(
482
+ ack &&
483
+ ack.sessionName === this.sessionName &&
484
+ ack.challenge === challenge &&
485
+ ack.peerPublicKey === this.identity.publicKey &&
486
+ ack.publicKey === peerPublicKey &&
487
+ typeof ack.signature === "string" &&
488
+ verifyText(
489
+ buildAckMessage(this.sessionName, challenge, peerPublicKey, this.identity.publicKey),
490
+ ack.signature,
491
+ peerPublicKey
492
+ )
493
+ );
494
+
495
+ this.trustState.challenge = challenge;
496
+ this.trustState.peerPublicKey = peerPublicKey;
497
+ this.trustState.phase = paired ? "paired" : "waiting_for_pair_ack";
498
+ this.trustState.pairedAt = paired ? (ack.signedAt || new Date().toISOString()) : null;
499
+ }
500
+
501
+ isPaired() {
502
+ return this.trustState.phase === "paired" &&
503
+ typeof this.trustState.challenge === "string" &&
504
+ typeof this.trustState.peerPublicKey === "string";
505
+ }
506
+
275
507
  launchShell() {
276
508
  ensureDir(this.paths.dir);
277
509
  fs.rmSync(this.paths.pipePath, { force: true });
278
510
  clearStaleStopRequest(this.sessionPaths.stopPath, this.startedAtMs);
511
+ this.initializeTrustMaterial();
279
512
 
280
513
  const shell = resolveShell();
281
514
  const shellArgs = resolveShellArgs(shell);
@@ -294,7 +527,7 @@ class ArmedSeat {
294
527
 
295
528
  this.childPid = this.child.pid;
296
529
  this.writeMeta();
297
- this.writeStatus({ state: "running" });
530
+ this.writeStatus({ state: "running", trust: this.trustState.phase });
298
531
 
299
532
  this.child.onData((data) => {
300
533
  fs.appendFileSync(this.paths.pipePath, data);
@@ -441,7 +674,7 @@ class ArmedSeat {
441
674
  async pullPartnerEvents() {
442
675
  const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
443
676
  this.partnerOffset = nextOffset;
444
- if (!text.trim() || !this.child || this.stopped) {
677
+ if (!text.trim() || !this.child || this.stopped || !this.isPaired()) {
445
678
  return;
446
679
  }
447
680
 
@@ -453,7 +686,20 @@ class ArmedSeat {
453
686
  }
454
687
 
455
688
  const payload = sanitizeRelayText(entry.text);
456
- if (!payload) {
689
+ const signaturePayload = buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, {
690
+ id: entry.id,
691
+ seatId: entry.seatId,
692
+ origin: entry.origin || "unknown",
693
+ createdAt: entry.createdAt,
694
+ text: payload,
695
+ });
696
+ if (
697
+ !payload ||
698
+ entry.challenge !== this.trustState.challenge ||
699
+ entry.publicKey !== this.trustState.peerPublicKey ||
700
+ typeof entry.signature !== "string" ||
701
+ !verifyText(signaturePayload, entry.signature, this.trustState.peerPublicKey)
702
+ ) {
457
703
  continue;
458
704
  }
459
705
 
@@ -570,7 +816,9 @@ class ArmedSeat {
570
816
  if (!this.liveState.sessionFile) {
571
817
  this.liveState.sessionFile = resolveSessionFile(
572
818
  detectedAgent.type,
819
+ detectedAgent.pid,
573
820
  currentPath,
821
+ this.liveState.captureSinceMs,
574
822
  detectedAgent.processStartedAtMs
575
823
  );
576
824
 
@@ -643,7 +891,7 @@ class ArmedSeat {
643
891
  }
644
892
 
645
893
  const payload = sanitizeRelayText(entry.text);
646
- if (!payload) {
894
+ if (!payload || !this.identity || !this.trustState.challenge) {
647
895
  return;
648
896
  }
649
897
 
@@ -653,14 +901,21 @@ class ArmedSeat {
653
901
  return;
654
902
  }
655
903
 
656
- appendJsonl(this.paths.eventsPath, {
904
+ const signedEntry = {
657
905
  id: entry.id || createId(12),
658
906
  type: "answer",
659
907
  seatId: this.seatId,
660
908
  origin: entry.origin || "unknown",
661
909
  text: payload,
662
910
  createdAt: entry.createdAt || new Date().toISOString(),
663
- });
911
+ challenge: this.trustState.challenge,
912
+ publicKey: this.identity.publicKey,
913
+ };
914
+ signedEntry.signature = signText(
915
+ buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, signedEntry),
916
+ this.identity.privateKey
917
+ );
918
+ appendJsonl(this.paths.eventsPath, signedEntry);
664
919
 
665
920
  this.log(`[${this.seatId}] ${previewText(payload)}`);
666
921
  }
@@ -670,11 +925,13 @@ class ArmedSeat {
670
925
  this.writeStatus({
671
926
  state: "stopping",
672
927
  partnerLive: this.partnerIsLive(),
928
+ trust: this.trustState.phase,
673
929
  });
674
930
  this.requestStop("stop_requested");
675
931
  return;
676
932
  }
677
933
 
934
+ this.syncTrustState();
678
935
  await this.pullPartnerEvents();
679
936
  if (this.stopped || this.stopRequested()) {
680
937
  this.requestStop("stop_requested");
@@ -693,6 +950,8 @@ class ArmedSeat {
693
950
  log: live.log,
694
951
  lastAnswerAt: live.lastAnswerAt,
695
952
  partnerLive: this.partnerIsLive(),
953
+ trust: this.trustState.phase,
954
+ challengeReady: Boolean(this.trustState.challenge),
696
955
  });
697
956
  }
698
957
 
@@ -704,6 +963,11 @@ class ArmedSeat {
704
963
 
705
964
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
706
965
  this.log("Use this shell normally. Codex, Claude, and Gemini final answers relay automatically from their local session logs.");
966
+ if (this.seatId === 1) {
967
+ this.log("Seat 1 generated the session key and is waiting for seat 2 to sign it.");
968
+ } else {
969
+ this.log("Seat 2 will sign the session key from seat 1, then relay goes live.");
970
+ }
707
971
  this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
708
972
 
709
973
  try {
@@ -797,6 +1061,7 @@ function buildSeatReport(sessionName, seatId) {
797
1061
  relayCount: status?.relayCount || 0,
798
1062
  log: status?.log || null,
799
1063
  startedAt: meta?.startedAt || null,
1064
+ trust: status?.trust || null,
800
1065
  updatedAt: status?.updatedAt || null,
801
1066
  lastAnswerAt: status?.lastAnswerAt || null,
802
1067
  partnerLive: Boolean(status?.partnerLive),
package/src/util.js CHANGED
@@ -1,4 +1,10 @@
1
- const { createHash, randomBytes } = require("node:crypto");
1
+ const {
2
+ createHash,
3
+ generateKeyPairSync,
4
+ randomBytes,
5
+ sign: cryptoSign,
6
+ verify: cryptoVerify,
7
+ } = require("node:crypto");
2
8
  const fs = require("node:fs");
3
9
  const os = require("node:os");
4
10
  const path = require("node:path");
@@ -28,6 +34,13 @@ function writeJson(filePath, value) {
28
34
  fs.renameSync(tempPath, filePath);
29
35
  }
30
36
 
37
+ function writeSecret(filePath, value, mode = 0o600) {
38
+ ensureDir(path.dirname(filePath));
39
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
40
+ fs.writeFileSync(tempPath, String(value), { mode });
41
+ fs.renameSync(tempPath, filePath);
42
+ }
43
+
31
44
  function readJson(filePath, fallback = null) {
32
45
  try {
33
46
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
@@ -161,15 +174,63 @@ function getSeatDir(sessionName, seatId) {
161
174
  function getSeatPaths(sessionName, seatId) {
162
175
  const dir = getSeatDir(sessionName, seatId);
163
176
  return {
177
+ ackPath: path.join(dir, "ack.json"),
178
+ challengePath: path.join(dir, "challenge.json"),
179
+ claimPath: path.join(dir, "claim.json"),
164
180
  dir,
165
181
  daemonPath: path.join(dir, "daemon.json"),
166
182
  eventsPath: path.join(dir, "events.jsonl"),
183
+ privateKeyPath: path.join(dir, "id_ed25519"),
184
+ publicKeyPath: path.join(dir, "id_ed25519.pub"),
167
185
  metaPath: path.join(dir, "meta.json"),
168
186
  pipePath: path.join(dir, "pipe.log"),
169
187
  statusPath: path.join(dir, "status.json"),
170
188
  };
171
189
  }
172
190
 
191
+ function loadOrCreateSeatIdentity(paths) {
192
+ try {
193
+ const privateKey = fs.readFileSync(paths.privateKeyPath, "utf8").trim();
194
+ const publicKey = fs.readFileSync(paths.publicKeyPath, "utf8").trim();
195
+ if (privateKey && publicKey) {
196
+ return { privateKey, publicKey };
197
+ }
198
+ } catch {
199
+ // generate below
200
+ }
201
+
202
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
203
+ const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
204
+ const publicKeyPem = publicKey.export({ format: "pem", type: "spki" });
205
+ writeSecret(paths.privateKeyPath, privateKeyPem);
206
+ writeSecret(paths.publicKeyPath, publicKeyPem, 0o644);
207
+ return {
208
+ privateKey: String(privateKeyPem).trim(),
209
+ publicKey: String(publicKeyPem).trim(),
210
+ };
211
+ }
212
+
213
+ function signText(text, privateKey) {
214
+ return cryptoSign(null, Buffer.from(String(text || ""), "utf8"), privateKey).toString("base64");
215
+ }
216
+
217
+ function verifyText(text, signature, publicKey) {
218
+ if (!signature || !publicKey) {
219
+ return false;
220
+ }
221
+
222
+ try {
223
+ return cryptoVerify(
224
+ null,
225
+ Buffer.from(String(text || ""), "utf8"),
226
+ publicKey,
227
+ Buffer.from(String(signature || ""), "base64")
228
+ );
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
173
234
  function listSessionNames() {
174
235
  const sessionsRoot = path.join(getStateRoot(), "sessions");
175
236
  try {
@@ -195,9 +256,10 @@ function usage() {
195
256
  "Flow:",
196
257
  " 1. Run `muuuuse 1` in terminal one.",
197
258
  " 2. Run `muuuuse 2` in terminal two.",
198
- " 3. Use those armed shells normally.",
199
- " 4. Codex, Claude, and Gemini final answers relay automatically from their local session logs.",
200
- " 5. Run `muuuuse status` or `muuuuse stop` from any shell.",
259
+ " 3. Seat 1 generates the session key and seat 2 signs it automatically.",
260
+ " 4. Use those armed shells normally.",
261
+ " 5. Codex, Claude, and Gemini final answers relay automatically from their local session logs.",
262
+ " 6. Run `muuuuse status` or `muuuuse stop` from any shell.",
201
263
  "",
202
264
  "Notes:",
203
265
  " - No tmux.",
@@ -215,6 +277,7 @@ module.exports = {
215
277
  ensureDir,
216
278
  getDefaultSessionName,
217
279
  getFileSize,
280
+ loadOrCreateSeatIdentity,
218
281
  getSeatPaths,
219
282
  getSessionPaths,
220
283
  getStateRoot,
@@ -224,7 +287,10 @@ module.exports = {
224
287
  readAppendedText,
225
288
  readJson,
226
289
  sanitizeRelayText,
290
+ signText,
227
291
  sleep,
228
292
  usage,
293
+ verifyText,
229
294
  writeJson,
295
+ writeSecret,
230
296
  };