muuuuse 1.4.1 → 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.1",
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,12 +527,15 @@ 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);
301
534
  if (!this.stopped) {
302
- process.stdout.write(data);
535
+ const visibleData = String(data).replace(/\u0007/g, "");
536
+ if (visibleData) {
537
+ process.stdout.write(visibleData);
538
+ }
303
539
  }
304
540
  });
305
541
 
@@ -438,7 +674,7 @@ class ArmedSeat {
438
674
  async pullPartnerEvents() {
439
675
  const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
440
676
  this.partnerOffset = nextOffset;
441
- if (!text.trim() || !this.child || this.stopped) {
677
+ if (!text.trim() || !this.child || this.stopped || !this.isPaired()) {
442
678
  return;
443
679
  }
444
680
 
@@ -450,7 +686,20 @@ class ArmedSeat {
450
686
  }
451
687
 
452
688
  const payload = sanitizeRelayText(entry.text);
453
- 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
+ ) {
454
703
  continue;
455
704
  }
456
705
 
@@ -567,7 +816,9 @@ class ArmedSeat {
567
816
  if (!this.liveState.sessionFile) {
568
817
  this.liveState.sessionFile = resolveSessionFile(
569
818
  detectedAgent.type,
819
+ detectedAgent.pid,
570
820
  currentPath,
821
+ this.liveState.captureSinceMs,
571
822
  detectedAgent.processStartedAtMs
572
823
  );
573
824
 
@@ -640,7 +891,7 @@ class ArmedSeat {
640
891
  }
641
892
 
642
893
  const payload = sanitizeRelayText(entry.text);
643
- if (!payload) {
894
+ if (!payload || !this.identity || !this.trustState.challenge) {
644
895
  return;
645
896
  }
646
897
 
@@ -650,14 +901,21 @@ class ArmedSeat {
650
901
  return;
651
902
  }
652
903
 
653
- appendJsonl(this.paths.eventsPath, {
904
+ const signedEntry = {
654
905
  id: entry.id || createId(12),
655
906
  type: "answer",
656
907
  seatId: this.seatId,
657
908
  origin: entry.origin || "unknown",
658
909
  text: payload,
659
910
  createdAt: entry.createdAt || new Date().toISOString(),
660
- });
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);
661
919
 
662
920
  this.log(`[${this.seatId}] ${previewText(payload)}`);
663
921
  }
@@ -667,11 +925,13 @@ class ArmedSeat {
667
925
  this.writeStatus({
668
926
  state: "stopping",
669
927
  partnerLive: this.partnerIsLive(),
928
+ trust: this.trustState.phase,
670
929
  });
671
930
  this.requestStop("stop_requested");
672
931
  return;
673
932
  }
674
933
 
934
+ this.syncTrustState();
675
935
  await this.pullPartnerEvents();
676
936
  if (this.stopped || this.stopRequested()) {
677
937
  this.requestStop("stop_requested");
@@ -690,6 +950,8 @@ class ArmedSeat {
690
950
  log: live.log,
691
951
  lastAnswerAt: live.lastAnswerAt,
692
952
  partnerLive: this.partnerIsLive(),
953
+ trust: this.trustState.phase,
954
+ challengeReady: Boolean(this.trustState.challenge),
693
955
  });
694
956
  }
695
957
 
@@ -701,6 +963,11 @@ class ArmedSeat {
701
963
 
702
964
  this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
703
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
+ }
704
971
  this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
705
972
 
706
973
  try {
@@ -731,6 +998,9 @@ class ArmedSeat {
731
998
  }
732
999
 
733
1000
  if (this.child && !this.childExit) {
1001
+ if (this.childPid && isPidAlive(this.childPid)) {
1002
+ signalProcessFamily(this.childPid, "SIGKILL");
1003
+ }
734
1004
  try {
735
1005
  this.child.kill();
736
1006
  } catch {
@@ -791,6 +1061,7 @@ function buildSeatReport(sessionName, seatId) {
791
1061
  relayCount: status?.relayCount || 0,
792
1062
  log: status?.log || null,
793
1063
  startedAt: meta?.startedAt || null,
1064
+ trust: status?.trust || null,
794
1065
  updatedAt: status?.updatedAt || null,
795
1066
  lastAnswerAt: status?.lastAnswerAt || null,
796
1067
  partnerLive: Boolean(status?.partnerLive),
@@ -848,9 +1119,7 @@ function stopAllSessions() {
848
1119
  if (seat.childLive) {
849
1120
  signalProcessFamily(seat.childPid, "SIGHUP");
850
1121
  signalProcessFamily(seat.childPid, "SIGTERM");
851
- if (!seat.wrapperLive) {
852
- signalProcessFamily(seat.childPid, "SIGKILL");
853
- }
1122
+ signalProcessFamily(seat.childPid, "SIGKILL");
854
1123
  }
855
1124
 
856
1125
  if (seat.wrapperLive) {
@@ -886,12 +1155,9 @@ function signalPid(pid, signal) {
886
1155
  }
887
1156
 
888
1157
  function signalProcessTree(rootPid, signal) {
889
- if (!Number.isInteger(rootPid) || rootPid <= 0) {
890
- return 0;
891
- }
892
-
1158
+ const descendants = getChildProcesses(rootPid);
893
1159
  let delivered = 0;
894
- for (const process of getChildProcesses(rootPid)) {
1160
+ for (const process of descendants) {
895
1161
  if (signalPid(process.pid, signal)) {
896
1162
  delivered += 1;
897
1163
  }
@@ -904,26 +1170,8 @@ function signalProcessTree(rootPid, signal) {
904
1170
  return delivered;
905
1171
  }
906
1172
 
907
- function signalProcessGroup(rootPid, signal) {
908
- if (!Number.isInteger(rootPid) || rootPid <= 0) {
909
- return false;
910
- }
911
-
912
- try {
913
- process.kill(-rootPid, signal);
914
- return true;
915
- } catch {
916
- return false;
917
- }
918
- }
919
-
920
1173
  function signalProcessFamily(rootPid, signal) {
921
- let delivered = 0;
922
- if (signalProcessGroup(rootPid, signal)) {
923
- delivered += 1;
924
- }
925
- delivered += signalProcessTree(rootPid, signal);
926
- return delivered;
1174
+ return signalProcessTree(rootPid, signal);
927
1175
  }
928
1176
 
929
1177
  function clearStaleStopRequest(stopPath, startedAtMs) {
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
  };