muuuuse 1.3.2 → 1.4.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/src/runtime.js CHANGED
@@ -1,10 +1,9 @@
1
1
  const fs = require("node:fs");
2
- const path = require("node:path");
2
+ const { execFileSync } = require("node:child_process");
3
3
  const pty = require("node-pty");
4
4
 
5
5
  const {
6
- detectAgentTypeFromCommand,
7
- expandPresetCommand,
6
+ detectAgent,
8
7
  readClaudeAnswers,
9
8
  readCodexAnswers,
10
9
  readGeminiAnswers,
@@ -17,189 +16,201 @@ const {
17
16
  POLL_MS,
18
17
  appendJsonl,
19
18
  createId,
19
+ ensureDir,
20
20
  getDefaultSessionName,
21
21
  getFileSize,
22
22
  getSeatPaths,
23
- getStateRoot,
23
+ getSessionPaths,
24
24
  hashText,
25
25
  isPidAlive,
26
+ listSessionNames,
26
27
  readAppendedText,
27
28
  readJson,
28
- resetDir,
29
29
  sanitizeRelayText,
30
30
  sleep,
31
31
  writeJson,
32
32
  } = require("./util");
33
33
 
34
- const GENERIC_IDLE_MS = 900;
34
+ const TYPE_DELAY_MS = 70;
35
+ const MIRROR_SUPPRESSION_WINDOW_MS = 30 * 1000;
36
+ const MAX_RECENT_INBOUND_RELAYS = 12;
37
+ const STOP_FORCE_KILL_MS = 1200;
35
38
 
36
- function resolveSessionName(sessionOverride, currentPath = process.cwd()) {
37
- return sessionOverride || getDefaultSessionName(currentPath);
39
+ function resolveShell() {
40
+ const shell = String(process.env.SHELL || "").trim();
41
+ return shell || "/bin/bash";
38
42
  }
39
43
 
40
- function resolveProgramTokens(commandTokens, usePresets = true) {
41
- const resolved = expandPresetCommand(commandTokens, usePresets);
42
- if (resolved.length === 0) {
43
- throw new Error("Seat commands now require a program. Example: `muuuuse 1 codex`.");
44
+ function resolveShellArgs(shellPath) {
45
+ const base = shellPath.split("/").pop();
46
+ if (base === "bash" || base === "zsh" || base === "fish") {
47
+ return ["-i"];
44
48
  }
45
- return resolved;
49
+ return [];
46
50
  }
47
51
 
48
- function formatCommand(commandTokens) {
49
- return commandTokens
50
- .map((token) => {
51
- if (/^[a-zA-Z0-9._/@:=+-]+$/.test(token)) {
52
- return token;
53
- }
54
- return JSON.stringify(token);
55
- })
56
- .join(" ");
52
+ function resolveChildTerm() {
53
+ const inherited = String(process.env.TERM || "").trim();
54
+ if (inherited && inherited.toLowerCase() !== "dumb") {
55
+ return inherited;
56
+ }
57
+ return "xterm-256color";
57
58
  }
58
59
 
59
- function previewText(text, maxLength = 88) {
60
- const compact = sanitizeRelayText(text).replace(/\s+/g, " ");
61
- if (compact.length <= maxLength) {
62
- return compact;
63
- }
64
- return `${compact.slice(0, maxLength - 3)}...`;
60
+ function resolveSessionName(currentPath = process.cwd()) {
61
+ return getDefaultSessionName(currentPath);
65
62
  }
66
63
 
67
64
  function parseAnswerEntries(text) {
68
65
  return String(text || "")
69
66
  .split("\n")
70
67
  .map((line) => line.trim())
71
- .filter((line) => line.length > 0)
68
+ .filter(Boolean)
72
69
  .map((line) => {
73
70
  try {
74
71
  return JSON.parse(line);
75
- } catch (error) {
72
+ } catch {
76
73
  return null;
77
74
  }
78
75
  })
79
76
  .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
80
77
  }
81
78
 
82
- function resolveSessionFile(agentType, currentPath, processStartedAtMs, options = {}) {
83
- if (agentType === "codex") {
84
- return selectCodexSessionFile(currentPath, processStartedAtMs, options);
85
- }
86
- if (agentType === "claude") {
87
- return selectClaudeSessionFile(currentPath, processStartedAtMs);
79
+ function readProcessCwd(pid) {
80
+ if (!Number.isInteger(pid) || pid <= 0) {
81
+ return null;
88
82
  }
89
- if (agentType === "gemini") {
90
- return selectGeminiSessionFile(currentPath, processStartedAtMs);
83
+
84
+ try {
85
+ return fs.realpathSync(`/proc/${pid}/cwd`);
86
+ } catch {
87
+ return null;
91
88
  }
92
- return null;
93
89
  }
94
90
 
95
- class GenericAnswerTracker {
96
- constructor() {
97
- this.active = false;
98
- this.buffer = "";
99
- this.lastInputText = "";
100
- this.lastOutputAt = 0;
101
- this.lastFingerprint = null;
91
+ function getChildProcesses(rootPid) {
92
+ if (!Number.isInteger(rootPid) || rootPid <= 0) {
93
+ return [];
102
94
  }
103
95
 
104
- noteTurnStart(inputText = "") {
105
- this.active = true;
106
- this.buffer = "";
107
- this.lastInputText = sanitizeRelayText(inputText);
108
- this.lastOutputAt = 0;
109
- }
96
+ try {
97
+ const output = execFileSync("ps", ["-axo", "pid=,ppid=,etimes=,command="], {
98
+ encoding: "utf8",
99
+ });
110
100
 
111
- append(data) {
112
- if (!this.active) {
113
- return;
101
+ const processes = output
102
+ .split("\n")
103
+ .map((line) => line.trim())
104
+ .filter((line) => line.length > 0)
105
+ .map((line) => {
106
+ const match = line.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
107
+ if (!match) {
108
+ return null;
109
+ }
110
+
111
+ return {
112
+ pid: Number.parseInt(match[1], 10),
113
+ ppid: Number.parseInt(match[2], 10),
114
+ elapsedSeconds: Number.parseInt(match[3], 10),
115
+ args: match[4],
116
+ };
117
+ })
118
+ .filter((entry) => entry !== null);
119
+
120
+ const descendants = [];
121
+ const queue = [rootPid];
122
+ const seen = new Set(queue);
123
+
124
+ while (queue.length > 0) {
125
+ const parentPid = queue.shift();
126
+ for (const process of processes) {
127
+ if (process.ppid !== parentPid || seen.has(process.pid)) {
128
+ continue;
129
+ }
130
+ seen.add(process.pid);
131
+ queue.push(process.pid);
132
+ descendants.push({
133
+ ...process,
134
+ cwd: readProcessCwd(process.pid),
135
+ });
136
+ }
114
137
  }
115
138
 
116
- this.buffer += String(data || "");
117
- this.lastOutputAt = Date.now();
139
+ return descendants.sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
118
144
 
119
- if (this.buffer.length > 24000) {
120
- this.buffer = this.buffer.slice(-24000);
121
- }
145
+ function resolveSessionFile(agentType, currentPath, processStartedAtMs) {
146
+ if (!currentPath) {
147
+ return null;
122
148
  }
123
149
 
124
- consumeReady() {
125
- if (!this.active || !this.lastOutputAt) {
126
- return null;
127
- }
150
+ if (agentType === "codex") {
151
+ return selectCodexSessionFile(currentPath, processStartedAtMs);
152
+ }
153
+ if (agentType === "claude") {
154
+ return selectClaudeSessionFile(currentPath, processStartedAtMs);
155
+ }
156
+ if (agentType === "gemini") {
157
+ return selectGeminiSessionFile(currentPath, processStartedAtMs);
158
+ }
159
+ return null;
160
+ }
128
161
 
129
- if (Date.now() - this.lastOutputAt < GENERIC_IDLE_MS) {
130
- return null;
131
- }
162
+ async function sendTextAndEnter(child, text, shouldAbort = () => false) {
163
+ const lines = String(text || "").replace(/\r/g, "").split("\n");
132
164
 
133
- const text = extractGenericAnswer(this.buffer, this.lastInputText);
134
- if (!text) {
135
- return null;
165
+ for (let index = 0; index < lines.length; index += 1) {
166
+ if (shouldAbort() || !child) {
167
+ return false;
136
168
  }
137
169
 
138
- const fingerprint = hashText(`${this.lastInputText}\n${text}`);
139
- if (fingerprint === this.lastFingerprint) {
140
- return null;
170
+ const line = lines[index];
171
+ if (line.length > 0) {
172
+ try {
173
+ child.write(line);
174
+ } catch {
175
+ return false;
176
+ }
177
+ await sleep(TYPE_DELAY_MS);
141
178
  }
142
179
 
143
- this.lastFingerprint = fingerprint;
144
- this.active = false;
145
- return text;
146
- }
147
- }
148
-
149
- function extractGenericAnswer(rawText, lastInputText) {
150
- let candidate = sanitizeRelayText(rawText, 12000);
151
- if (!candidate) {
152
- return null;
153
- }
180
+ if (index < lines.length - 1) {
181
+ if (shouldAbort()) {
182
+ return false;
183
+ }
154
184
 
155
- if (lastInputText) {
156
- if (candidate === lastInputText) {
157
- return null;
158
- }
159
- if (candidate.startsWith(`${lastInputText}\n`)) {
160
- candidate = candidate.slice(lastInputText.length).trim();
185
+ try {
186
+ child.write("\r");
187
+ } catch {
188
+ return false;
189
+ }
190
+ await sleep(TYPE_DELAY_MS);
161
191
  }
162
192
  }
163
193
 
164
- const markerAnswer = extractMarkedAnswer(candidate);
165
- if (markerAnswer) {
166
- return markerAnswer;
194
+ if (shouldAbort() || !child) {
195
+ return false;
167
196
  }
168
197
 
169
- const blocks = candidate
170
- .split(/\n{2,}/)
171
- .map((block) => block.trim())
172
- .filter((block) => block.length > 0);
173
-
174
- if (blocks.length === 0) {
175
- return null;
176
- }
177
-
178
- return sanitizeRelayText(blocks[blocks.length - 1]);
179
- }
180
-
181
- function extractMarkedAnswer(content) {
182
- const lines = String(content || "").split("\n");
183
- const answerIndex = lines.findIndex((line) => line.trim().startsWith("(answer)"));
184
- if (answerIndex === -1) {
185
- return null;
198
+ try {
199
+ child.write("\r");
200
+ } catch {
201
+ return false;
186
202
  }
187
203
 
188
- const answerLines = lines.slice(answerIndex);
189
- answerLines[0] = answerLines[0].trim().replace(/^\(answer\)\s*/, "");
190
- return sanitizeRelayText(answerLines.join("\n"));
204
+ return true;
191
205
  }
192
206
 
193
- class SeatProcess {
207
+ class ArmedSeat {
194
208
  constructor(options) {
195
209
  this.seatId = options.seatId;
196
210
  this.partnerSeatId = options.seatId === 1 ? 2 : 1;
197
- this.sessionName = options.sessionName;
198
211
  this.cwd = options.cwd;
199
- this.commandTokens = [...options.commandTokens];
200
- this.agentType = detectAgentTypeFromCommand(this.commandTokens);
201
- this.maxRelays = options.maxRelays;
202
-
212
+ this.sessionName = resolveSessionName(this.cwd);
213
+ this.sessionPaths = getSessionPaths(this.sessionName);
203
214
  this.paths = getSeatPaths(this.sessionName, this.seatId);
204
215
  this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
205
216
  this.partnerOffset = getFileSize(this.partnerPaths.eventsPath);
@@ -207,22 +218,26 @@ class SeatProcess {
207
218
  this.child = null;
208
219
  this.childPid = null;
209
220
  this.childExit = null;
221
+ this.startedAt = new Date().toISOString();
210
222
  this.startedAtMs = Date.now();
211
223
  this.relayCount = 0;
212
- this.linked = false;
213
224
  this.stopped = false;
225
+ this.stopReason = null;
214
226
  this.stdinCleanup = null;
215
227
  this.resizeCleanup = null;
216
- this.childToken = createId(16);
217
- this.processStartedAtMs = null;
218
-
219
- this.sessionState = {
220
- file: null,
228
+ this.forceKillTimer = null;
229
+ this.recentInboundRelays = [];
230
+ this.liveState = {
231
+ type: null,
232
+ pid: null,
233
+ currentPath: null,
234
+ sessionFile: null,
221
235
  offset: 0,
222
236
  lastMessageId: null,
237
+ processStartedAtMs: null,
238
+ captureSinceMs: this.startedAtMs,
239
+ lastAnswerAt: null,
223
240
  };
224
-
225
- this.genericTracker = new GenericAnswerTracker();
226
241
  }
227
242
 
228
243
  log(message) {
@@ -232,15 +247,13 @@ class SeatProcess {
232
247
  writeMeta(extra = {}) {
233
248
  writeJson(this.paths.metaPath, {
234
249
  seatId: this.seatId,
250
+ partnerSeatId: this.partnerSeatId,
235
251
  sessionName: this.sessionName,
236
252
  cwd: this.cwd,
237
253
  pid: process.pid,
238
254
  childPid: this.childPid,
239
- childToken: this.childToken,
240
- agentType: this.agentType,
241
- command: this.commandTokens,
242
- commandLine: formatCommand(this.commandTokens),
243
- startedAt: new Date(this.startedAtMs).toISOString(),
255
+ command: [resolveShell(), ...resolveShellArgs(resolveShell())],
256
+ startedAt: this.startedAt,
244
257
  ...extra,
245
258
  });
246
259
  }
@@ -248,26 +261,52 @@ class SeatProcess {
248
261
  writeStatus(extra = {}) {
249
262
  writeJson(this.paths.statusPath, {
250
263
  seatId: this.seatId,
264
+ partnerSeatId: this.partnerSeatId,
251
265
  sessionName: this.sessionName,
252
266
  cwd: this.cwd,
253
267
  pid: process.pid,
254
268
  childPid: this.childPid,
255
- childToken: this.childToken,
256
- agentType: this.agentType,
257
- command: this.commandTokens,
258
269
  relayCount: this.relayCount,
259
270
  updatedAt: new Date().toISOString(),
260
271
  ...extra,
261
272
  });
262
273
  }
263
274
 
264
- installSignalHandlers() {
265
- const stop = () => {
275
+ launchShell() {
276
+ ensureDir(this.paths.dir);
277
+ fs.rmSync(this.paths.pipePath, { force: true });
278
+ clearStaleStopRequest(this.sessionPaths.stopPath, this.startedAtMs);
279
+
280
+ const shell = resolveShell();
281
+ const shellArgs = resolveShellArgs(shell);
282
+ this.child = pty.spawn(shell, shellArgs, {
283
+ cols: process.stdout.columns || 120,
284
+ rows: process.stdout.rows || 36,
285
+ 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(),
293
+ });
294
+
295
+ this.childPid = this.child.pid;
296
+ this.writeMeta();
297
+ this.writeStatus({ state: "running" });
298
+
299
+ this.child.onData((data) => {
300
+ fs.appendFileSync(this.paths.pipePath, data);
301
+ if (!this.stopped) {
302
+ process.stdout.write(data);
303
+ }
304
+ });
305
+
306
+ this.child.onExit(({ exitCode, signal }) => {
307
+ this.childExit = { exitCode, signal: signal || null };
266
308
  this.stopped = true;
267
- };
268
- process.once("SIGINT", stop);
269
- process.once("SIGHUP", stop);
270
- process.once("SIGTERM", stop);
309
+ });
271
310
  }
272
311
 
273
312
  installStdinProxy() {
@@ -275,13 +314,9 @@ class SeatProcess {
275
314
  if (!this.child) {
276
315
  return;
277
316
  }
278
-
279
- const text = chunk.toString("utf8");
280
- this.child.write(text);
281
- if (this.shouldUseGenericCapture() && /[\r\n]/.test(text)) {
282
- this.genericTracker.noteTurnStart("");
283
- }
317
+ this.child.write(chunk.toString("utf8"));
284
318
  };
319
+
285
320
  const handleEnd = () => {
286
321
  this.stopped = true;
287
322
  };
@@ -315,9 +350,9 @@ class SeatProcess {
315
350
  }
316
351
 
317
352
  try {
318
- this.child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
319
- } catch (error) {
320
- // Ignore resize failures while the child is exiting.
353
+ this.child.resize(process.stdout.columns || 120, process.stdout.rows || 36);
354
+ } catch {
355
+ // Ignore resize races while the child is exiting.
321
356
  }
322
357
  };
323
358
 
@@ -327,80 +362,91 @@ class SeatProcess {
327
362
  };
328
363
  }
329
364
 
330
- launchChild() {
331
- resetDir(this.paths.dir);
365
+ installStopSignals() {
366
+ const requestStop = () => {
367
+ this.requestStop("signal");
368
+ };
332
369
 
333
- const [file, ...args] = this.commandTokens;
334
- this.child = pty.spawn(file, args, {
335
- cols: process.stdout.columns || 80,
336
- cwd: this.cwd,
337
- env: {
338
- ...process.env,
339
- MUUUUSE_CHILD_TOKEN: this.childToken,
340
- MUUUUSE_SEAT: String(this.seatId),
341
- MUUUUSE_SESSION: this.sessionName,
342
- },
343
- name: process.env.TERM || "xterm-256color",
344
- rows: process.stdout.rows || 24,
345
- });
370
+ process.on("SIGTERM", requestStop);
371
+ process.on("SIGHUP", requestStop);
372
+ }
346
373
 
347
- this.childPid = this.child.pid;
348
- this.processStartedAtMs = Date.now();
349
- this.writeMeta();
350
- this.writeStatus({
351
- partnerSeatId: this.partnerSeatId,
352
- state: "running",
353
- });
374
+ requestStop(reason = "stop_requested") {
375
+ if (this.stopped) {
376
+ return;
377
+ }
354
378
 
355
- this.child.onData((data) => {
356
- process.stdout.write(data);
357
- if (this.shouldUseGenericCapture()) {
358
- this.genericTracker.append(data);
359
- }
360
- });
379
+ this.stopped = true;
380
+ this.stopReason = reason;
361
381
 
362
- this.child.onExit(({ exitCode, signal }) => {
363
- this.childExit = {
364
- exitCode,
365
- signal: signal || null,
366
- };
367
- this.stopped = true;
368
- });
369
- }
382
+ if (this.childPid) {
383
+ signalProcessFamily(this.childPid, "SIGHUP");
384
+ signalProcessFamily(this.childPid, "SIGTERM");
385
+ this.scheduleForcedKill();
386
+ }
370
387
 
371
- partnerIsLive() {
372
- const partnerStatus = readJson(this.partnerPaths.statusPath, null);
373
- return Boolean(partnerStatus?.pid && isPidAlive(partnerStatus.pid));
388
+ if (this.child && !this.childExit) {
389
+ try {
390
+ this.child.kill();
391
+ } catch {
392
+ // best effort shutdown
393
+ }
394
+ }
374
395
  }
375
396
 
376
- maybeMarkLinked() {
377
- if (this.linked || !this.partnerIsLive()) {
397
+ scheduleForcedKill() {
398
+ if (this.forceKillTimer || !this.childPid) {
378
399
  return;
379
400
  }
380
401
 
381
- this.linked = true;
382
- this.log(`${BRAND} seat ${this.seatId} linked with seat ${this.partnerSeatId} in session ${this.sessionName}.`);
402
+ this.forceKillTimer = setTimeout(() => {
403
+ this.forceKillTimer = null;
404
+ if (!this.childPid || this.childExit) {
405
+ return;
406
+ }
407
+
408
+ signalProcessFamily(this.childPid, "SIGKILL");
409
+ if (this.child && !this.childExit) {
410
+ try {
411
+ this.child.kill();
412
+ } catch {
413
+ // best effort hard shutdown
414
+ }
415
+ }
416
+ }, STOP_FORCE_KILL_MS);
417
+
418
+ if (typeof this.forceKillTimer.unref === "function") {
419
+ this.forceKillTimer.unref();
420
+ }
421
+ }
422
+
423
+ partnerIsLive() {
424
+ const partner = readJson(this.partnerPaths.statusPath, null);
425
+ return Boolean(partner?.pid && isPidAlive(partner.pid));
383
426
  }
384
427
 
385
- shouldUseGenericCapture() {
386
- return !this.agentType;
428
+ stopRequested() {
429
+ const request = readJson(this.sessionPaths.stopPath, null);
430
+ if (!request?.requestedAt) {
431
+ return false;
432
+ }
433
+
434
+ const requestedAtMs = Date.parse(request.requestedAt);
435
+ return Number.isFinite(requestedAtMs) && requestedAtMs > this.startedAtMs;
387
436
  }
388
437
 
389
- pullPartnerEvents() {
438
+ async pullPartnerEvents() {
390
439
  const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
391
440
  this.partnerOffset = nextOffset;
392
- if (!text.trim()) {
441
+ if (!text.trim() || !this.child || this.stopped) {
393
442
  return;
394
443
  }
395
444
 
396
445
  const entries = parseAnswerEntries(text);
397
446
  for (const entry of entries) {
398
- if (!this.child) {
399
- continue;
400
- }
401
- if (Number.isFinite(this.maxRelays) && this.relayCount >= this.maxRelays) {
402
- this.log(`${BRAND} seat ${this.seatId} hit the relay cap (${this.maxRelays}).`);
403
- continue;
447
+ if (this.stopped || this.stopRequested()) {
448
+ this.requestStop("stop_requested");
449
+ return;
404
450
  }
405
451
 
406
452
  const payload = sanitizeRelayText(entry.text);
@@ -408,98 +454,199 @@ class SeatProcess {
408
454
  continue;
409
455
  }
410
456
 
411
- if (this.shouldUseGenericCapture()) {
412
- this.genericTracker.noteTurnStart(payload);
457
+ const delivered = await sendTextAndEnter(
458
+ this.child,
459
+ payload,
460
+ () => this.stopped || this.stopRequested() || !this.child || Boolean(this.childExit)
461
+ );
462
+ if (!delivered) {
463
+ this.requestStop("relay_aborted");
464
+ return;
465
+ }
466
+
467
+ if (this.stopped || this.stopRequested()) {
468
+ this.requestStop("stop_requested");
469
+ return;
413
470
  }
414
471
 
415
- this.child.write(payload.replace(/\n/g, "\r"));
416
- this.child.write("\r");
417
472
  this.relayCount += 1;
473
+ this.rememberInboundRelay(payload);
418
474
  this.log(`[${this.partnerSeatId} -> ${this.seatId}] ${previewText(payload)}`);
419
475
  }
420
476
  }
421
477
 
422
- resolveStructuredLog() {
423
- if (!this.agentType || this.sessionState.file) {
478
+ rememberInboundRelay(text) {
479
+ const payload = sanitizeRelayText(text);
480
+ if (!payload) {
424
481
  return;
425
482
  }
426
483
 
427
- const sessionFile = resolveSessionFile(this.agentType, this.cwd, this.processStartedAtMs, {
428
- snapshotEnv: this.agentType === "codex"
429
- ? {
430
- MUUUUSE_CHILD_TOKEN: this.childToken,
431
- MUUUUSE_SEAT: String(this.seatId),
432
- MUUUUSE_SESSION: this.sessionName,
433
- }
434
- : null,
484
+ const now = Date.now();
485
+ this.pruneRecentInboundRelays(now);
486
+ this.recentInboundRelays.push({
487
+ hash: hashText(payload),
488
+ text: payload,
489
+ timestampMs: now,
435
490
  });
436
- if (!sessionFile) {
437
- return;
438
- }
439
491
 
440
- this.sessionState.file = sessionFile;
441
- if (this.agentType === "gemini") {
442
- const baseline = readGeminiAnswers(sessionFile, null);
443
- this.sessionState.lastMessageId = baseline.lastMessageId;
444
- this.sessionState.offset = baseline.fileSize;
445
- } else {
446
- this.sessionState.offset = getFileSize(sessionFile);
492
+ if (this.recentInboundRelays.length > MAX_RECENT_INBOUND_RELAYS) {
493
+ this.recentInboundRelays = this.recentInboundRelays.slice(-MAX_RECENT_INBOUND_RELAYS);
447
494
  }
448
495
  }
449
496
 
450
- collectStructuredAnswers() {
451
- this.resolveStructuredLog();
452
- if (!this.sessionState.file || !this.agentType) {
453
- return;
497
+ pruneRecentInboundRelays(now = Date.now()) {
498
+ this.recentInboundRelays = this.recentInboundRelays.filter(
499
+ (entry) => now - entry.timestampMs <= MIRROR_SUPPRESSION_WINDOW_MS
500
+ );
501
+ }
502
+
503
+ takeMirroredInboundRelay(payload) {
504
+ const normalized = sanitizeRelayText(payload);
505
+ if (!normalized) {
506
+ return null;
507
+ }
508
+
509
+ this.pruneRecentInboundRelays();
510
+ const payloadHash = hashText(normalized);
511
+ const matchIndex = this.recentInboundRelays.findIndex((entry) => entry.hash === payloadHash);
512
+ if (matchIndex === -1) {
513
+ return null;
514
+ }
515
+
516
+ const [match] = this.recentInboundRelays.splice(matchIndex, 1);
517
+ return match;
518
+ }
519
+
520
+ collectLiveAnswers() {
521
+ const detectedAgent = detectAgent(getChildProcesses(this.childPid));
522
+ if (!detectedAgent) {
523
+ this.liveState = {
524
+ type: null,
525
+ pid: null,
526
+ currentPath: null,
527
+ sessionFile: null,
528
+ offset: 0,
529
+ lastMessageId: null,
530
+ processStartedAtMs: null,
531
+ captureSinceMs: this.startedAtMs,
532
+ lastAnswerAt: null,
533
+ };
534
+
535
+ return {
536
+ state: this.childExit ? "exited" : "running",
537
+ agent: null,
538
+ cwd: this.cwd,
539
+ log: null,
540
+ lastAnswerAt: null,
541
+ };
542
+ }
543
+
544
+ const currentPath = detectedAgent.cwd || this.cwd;
545
+ const changed =
546
+ this.liveState.type !== detectedAgent.type ||
547
+ this.liveState.pid !== detectedAgent.pid ||
548
+ this.liveState.currentPath !== currentPath;
549
+
550
+ if (changed) {
551
+ this.liveState = {
552
+ type: detectedAgent.type,
553
+ pid: detectedAgent.pid,
554
+ currentPath,
555
+ sessionFile: null,
556
+ offset: 0,
557
+ lastMessageId: null,
558
+ processStartedAtMs: detectedAgent.processStartedAtMs,
559
+ captureSinceMs: Math.max(
560
+ this.startedAtMs,
561
+ Number.isFinite(detectedAgent.processStartedAtMs) ? detectedAgent.processStartedAtMs : 0
562
+ ),
563
+ lastAnswerAt: null,
564
+ };
565
+ }
566
+
567
+ if (!this.liveState.sessionFile) {
568
+ this.liveState.sessionFile = resolveSessionFile(
569
+ detectedAgent.type,
570
+ currentPath,
571
+ detectedAgent.processStartedAtMs
572
+ );
573
+
574
+ if (this.liveState.sessionFile) {
575
+ this.liveState.offset = 0;
576
+ this.liveState.lastMessageId = null;
577
+ }
578
+ }
579
+
580
+ if (!this.liveState.sessionFile) {
581
+ return {
582
+ state: "running",
583
+ agent: detectedAgent.type,
584
+ cwd: currentPath,
585
+ log: "waiting_for_session_log",
586
+ lastAnswerAt: this.liveState.lastAnswerAt,
587
+ };
454
588
  }
455
589
 
456
590
  const answers = [];
457
- if (this.agentType === "codex") {
458
- const result = readCodexAnswers(this.sessionState.file, this.sessionState.offset);
459
- this.sessionState.offset = result.nextOffset;
591
+ if (detectedAgent.type === "codex") {
592
+ const result = readCodexAnswers(
593
+ this.liveState.sessionFile,
594
+ this.liveState.offset,
595
+ this.liveState.captureSinceMs
596
+ );
597
+ this.liveState.offset = result.nextOffset;
460
598
  answers.push(...result.answers);
461
- } else if (this.agentType === "claude") {
462
- const result = readClaudeAnswers(this.sessionState.file, this.sessionState.offset);
463
- this.sessionState.offset = result.nextOffset;
599
+ } else if (detectedAgent.type === "claude") {
600
+ const result = readClaudeAnswers(
601
+ this.liveState.sessionFile,
602
+ this.liveState.offset,
603
+ this.liveState.captureSinceMs
604
+ );
605
+ this.liveState.offset = result.nextOffset;
464
606
  answers.push(...result.answers);
465
- } else if (this.agentType === "gemini") {
466
- const result = readGeminiAnswers(this.sessionState.file, this.sessionState.lastMessageId);
467
- this.sessionState.lastMessageId = result.lastMessageId;
468
- this.sessionState.offset = result.fileSize;
607
+ } else if (detectedAgent.type === "gemini") {
608
+ const result = readGeminiAnswers(
609
+ this.liveState.sessionFile,
610
+ this.liveState.lastMessageId,
611
+ this.liveState.captureSinceMs
612
+ );
613
+ this.liveState.lastMessageId = result.lastMessageId;
614
+ this.liveState.offset = result.fileSize;
469
615
  answers.push(...result.answers);
470
616
  }
471
617
 
472
618
  for (const answer of answers) {
473
619
  this.emitAnswer({
474
- createdAt: answer.timestamp,
475
- id: answer.id,
476
- origin: this.agentType,
620
+ id: answer.id || createId(12),
621
+ origin: detectedAgent.type,
477
622
  text: answer.text,
623
+ createdAt: answer.timestamp || new Date().toISOString(),
478
624
  });
625
+ this.liveState.lastAnswerAt = answer.timestamp || new Date().toISOString();
479
626
  }
627
+
628
+ return {
629
+ state: "running",
630
+ agent: detectedAgent.type,
631
+ cwd: currentPath,
632
+ log: this.liveState.sessionFile,
633
+ lastAnswerAt: this.liveState.lastAnswerAt,
634
+ };
480
635
  }
481
636
 
482
- collectGenericAnswers() {
483
- if (!this.shouldUseGenericCapture()) {
637
+ emitAnswer(entry) {
638
+ if (this.stopped) {
484
639
  return;
485
640
  }
486
641
 
487
- const text = this.genericTracker.consumeReady();
488
- if (!text) {
642
+ const payload = sanitizeRelayText(entry.text);
643
+ if (!payload) {
489
644
  return;
490
645
  }
491
646
 
492
- this.emitAnswer({
493
- createdAt: new Date().toISOString(),
494
- id: createId(12),
495
- origin: "generic",
496
- text,
497
- });
498
- }
499
-
500
- emitAnswer(entry) {
501
- const text = sanitizeRelayText(entry.text);
502
- if (!text) {
647
+ const mirroredInbound = this.takeMirroredInboundRelay(payload);
648
+ if (mirroredInbound) {
649
+ this.log(`[${this.seatId}] suppressed mirrored relay: ${previewText(payload)}`);
503
650
  return;
504
651
  }
505
652
 
@@ -508,36 +655,53 @@ class SeatProcess {
508
655
  type: "answer",
509
656
  seatId: this.seatId,
510
657
  origin: entry.origin || "unknown",
511
- text,
658
+ text: payload,
512
659
  createdAt: entry.createdAt || new Date().toISOString(),
513
660
  });
514
661
 
515
- this.log(`[${this.seatId}] ${previewText(text)}`);
662
+ this.log(`[${this.seatId}] ${previewText(payload)}`);
516
663
  }
517
664
 
518
665
  async tick() {
519
- this.maybeMarkLinked();
520
- this.pullPartnerEvents();
521
- this.collectStructuredAnswers();
522
- this.collectGenericAnswers();
666
+ if (this.stopRequested()) {
667
+ this.writeStatus({
668
+ state: "stopping",
669
+ partnerLive: this.partnerIsLive(),
670
+ });
671
+ this.requestStop("stop_requested");
672
+ return;
673
+ }
674
+
675
+ await this.pullPartnerEvents();
676
+ if (this.stopped || this.stopRequested()) {
677
+ this.requestStop("stop_requested");
678
+ return;
679
+ }
680
+
681
+ const live = this.collectLiveAnswers();
682
+ if (this.stopped) {
683
+ return;
684
+ }
523
685
 
524
686
  this.writeStatus({
525
- partnerSeatId: this.partnerSeatId,
687
+ state: live.state,
688
+ agent: live.agent,
689
+ cwd: live.cwd,
690
+ log: live.log,
691
+ lastAnswerAt: live.lastAnswerAt,
526
692
  partnerLive: this.partnerIsLive(),
527
- state: this.childExit ? "exited" : "running",
528
- structuredLog: this.sessionState.file,
529
693
  });
530
694
  }
531
695
 
532
696
  async run() {
533
- this.installSignalHandlers();
534
- this.launchChild();
697
+ this.installStopSignals();
698
+ this.launchShell();
535
699
  this.installStdinProxy();
536
700
  this.installResizeHandler();
537
701
 
538
- this.log(`${BRAND} seat ${this.seatId} started in session ${this.sessionName}.`);
539
- this.log(`Command: ${formatCommand(this.commandTokens)}`);
540
- this.log(`Stop everything from another terminal with: muuuuse stop`);
702
+ this.log(`${BRAND} seat ${this.seatId} armed for ${this.sessionName}.`);
703
+ this.log("Use this shell normally. Codex, Claude, and Gemini final answers relay automatically from their local session logs.");
704
+ this.log("Run `muuuuse status` or `muuuuse stop` from any terminal.");
541
705
 
542
706
  try {
543
707
  while (!this.stopped) {
@@ -552,6 +716,11 @@ class SeatProcess {
552
716
  }
553
717
 
554
718
  cleanup() {
719
+ if (this.forceKillTimer) {
720
+ clearTimeout(this.forceKillTimer);
721
+ this.forceKillTimer = null;
722
+ }
723
+
555
724
  if (this.stdinCleanup) {
556
725
  this.stdinCleanup();
557
726
  this.stdinCleanup = null;
@@ -563,9 +732,9 @@ class SeatProcess {
563
732
 
564
733
  if (this.child && !this.childExit) {
565
734
  try {
566
- this.child.kill("SIGTERM");
567
- } catch (error) {
568
- // Ignore races during shutdown.
735
+ this.child.kill();
736
+ } catch {
737
+ // best effort
569
738
  }
570
739
  }
571
740
 
@@ -575,135 +744,218 @@ class SeatProcess {
575
744
  });
576
745
  this.writeStatus({
577
746
  childPid: this.childPid,
578
- exitCode: this.childExit?.exitCode ?? null,
579
747
  exitedAt: new Date().toISOString(),
580
- partnerSeatId: this.partnerSeatId,
581
748
  state: "exited",
582
749
  });
583
750
  }
584
751
  }
585
752
 
586
- function stopSession(sessionName) {
587
- const results = [];
753
+ function previewText(text, maxLength = 88) {
754
+ const compact = sanitizeRelayText(text).replace(/\s+/g, " ");
755
+ if (compact.length <= maxLength) {
756
+ return compact;
757
+ }
758
+ return `${compact.slice(0, maxLength - 3)}...`;
759
+ }
588
760
 
589
- for (const seatId of [1, 2]) {
590
- const paths = getSeatPaths(sessionName, seatId);
591
- const status = readJson(paths.statusPath, null);
592
- const meta = readJson(paths.metaPath, null);
593
- const wrapperPid = status?.pid || meta?.pid || null;
594
- const childPid = status?.childPid || meta?.childPid || null;
761
+ function buildSeatReport(sessionName, seatId) {
762
+ const paths = getSeatPaths(sessionName, seatId);
763
+ const daemon = readJson(paths.daemonPath, null);
764
+ const status = readJson(paths.statusPath, null);
765
+ const meta = readJson(paths.metaPath, null);
595
766
 
596
- let wrapperStopped = false;
597
- let childStopped = false;
767
+ if (!status && !meta && !daemon) {
768
+ return null;
769
+ }
598
770
 
599
- if (wrapperPid && isPidAlive(wrapperPid)) {
600
- try {
601
- process.kill(wrapperPid, "SIGTERM");
602
- wrapperStopped = true;
603
- } catch (error) {
604
- wrapperStopped = false;
605
- }
606
- }
771
+ const legacyTmux = Boolean(daemon?.pid || meta?.paneId);
772
+ const wrapperPid = status?.pid || daemon?.pid || meta?.pid || null;
773
+ const childPid = status?.childPid || meta?.childPid || null;
774
+ const wrapperLive = isPidAlive(wrapperPid);
775
+ const childLive = isPidAlive(childPid);
607
776
 
608
- if (childPid && isPidAlive(childPid)) {
609
- try {
610
- process.kill(childPid, "SIGTERM");
611
- childStopped = true;
612
- } catch (error) {
613
- childStopped = false;
614
- }
615
- }
616
-
617
- results.push({
618
- seatId,
619
- childPid,
620
- childStopped,
621
- wrapperPid,
622
- wrapperStopped,
623
- });
777
+ if (!wrapperLive && !childLive) {
778
+ return null;
624
779
  }
625
780
 
626
781
  return {
627
- sessionName,
628
- seats: results,
782
+ seatId,
783
+ state: wrapperLive ? status?.state || "running" : "orphaned_child",
784
+ wrapperPid,
785
+ childPid,
786
+ wrapperLive,
787
+ childLive,
788
+ legacyTmux,
789
+ agent: status?.agent || null,
790
+ cwd: status?.cwd || meta?.cwd || null,
791
+ relayCount: status?.relayCount || 0,
792
+ log: status?.log || null,
793
+ startedAt: meta?.startedAt || null,
794
+ updatedAt: status?.updatedAt || null,
795
+ lastAnswerAt: status?.lastAnswerAt || null,
796
+ partnerLive: Boolean(status?.partnerLive),
629
797
  };
630
798
  }
631
799
 
632
- function listSessionNames() {
633
- const sessionsRoot = path.join(getStateRoot(), "sessions");
634
- try {
635
- return fs.readdirSync(sessionsRoot, { withFileTypes: true })
636
- .filter((entry) => entry.isDirectory())
637
- .map((entry) => entry.name)
638
- .sort();
639
- } catch (error) {
640
- return [];
641
- }
800
+ function getStatusReport() {
801
+ const sessions = listSessionNames()
802
+ .map((sessionName) => {
803
+ const sessionPaths = getSessionPaths(sessionName);
804
+ const controller = readJson(sessionPaths.controllerPath, null);
805
+ const stopRequest = readJson(sessionPaths.stopPath, null);
806
+ const seats = [1, 2]
807
+ .map((seatId) => buildSeatReport(sessionName, seatId))
808
+ .filter((entry) => entry !== null);
809
+
810
+ const controllerPid = controller?.pid || null;
811
+ const controllerLive = isPidAlive(controllerPid);
812
+
813
+ if (seats.length === 0 && !controllerLive) {
814
+ return null;
815
+ }
816
+
817
+ const stopRequestedAt = selectVisibleStopRequest(stopRequest?.requestedAt, seats);
818
+
819
+ return {
820
+ sessionName,
821
+ controllerPid,
822
+ controllerLive,
823
+ stopRequestedAt,
824
+ seats,
825
+ };
826
+ })
827
+ .filter((entry) => entry !== null);
828
+
829
+ return { sessions };
642
830
  }
643
831
 
644
- async function stopSessions(sessionName = null) {
645
- const sessionNames = sessionName ? [sessionName] : listSessionNames();
646
- const sessionResults = sessionNames.map((name) => stopSession(name));
832
+ function stopAllSessions() {
833
+ const report = getStatusReport();
834
+ const requestedAt = new Date().toISOString();
647
835
 
648
- await sleep(200);
836
+ for (const session of report.sessions) {
837
+ const sessionPaths = getSessionPaths(session.sessionName);
838
+ writeJson(sessionPaths.stopPath, {
839
+ requestId: createId(12),
840
+ requestedAt,
841
+ });
649
842
 
650
- for (const sessionResult of sessionResults) {
651
- for (const seat of sessionResult.seats) {
652
- if (seat.wrapperPid && isPidAlive(seat.wrapperPid)) {
653
- try {
654
- process.kill(seat.wrapperPid, "SIGKILL");
655
- seat.wrapperForced = true;
656
- } catch (error) {
657
- seat.wrapperForced = false;
843
+ if (session.controllerLive) {
844
+ signalPid(session.controllerPid, "SIGTERM");
845
+ }
846
+
847
+ for (const seat of session.seats) {
848
+ if (seat.childLive) {
849
+ signalProcessFamily(seat.childPid, "SIGHUP");
850
+ signalProcessFamily(seat.childPid, "SIGTERM");
851
+ if (!seat.wrapperLive) {
852
+ signalProcessFamily(seat.childPid, "SIGKILL");
658
853
  }
659
- } else {
660
- seat.wrapperForced = false;
661
854
  }
662
855
 
663
- if (seat.childPid && isPidAlive(seat.childPid)) {
664
- try {
665
- process.kill(seat.childPid, "SIGKILL");
666
- seat.childForced = true;
667
- } catch (error) {
668
- seat.childForced = false;
669
- }
670
- } else {
671
- seat.childForced = false;
856
+ if (seat.wrapperLive) {
857
+ signalPid(seat.wrapperPid, "SIGTERM");
672
858
  }
673
859
  }
674
860
  }
675
861
 
676
862
  return {
677
- sessionName,
678
- sessions: sessionResults,
863
+ requestedAt,
864
+ sessions: report.sessions,
679
865
  };
680
866
  }
681
867
 
682
- function readSessionStatus(sessionName) {
683
- return {
684
- sessionName,
685
- seats: [1, 2].map((seatId) => {
686
- const paths = getSeatPaths(sessionName, seatId);
687
- const status = readJson(paths.statusPath, null);
688
- return {
689
- seatId,
690
- status,
691
- };
692
- }),
693
- };
868
+ module.exports = {
869
+ ArmedSeat,
870
+ getStatusReport,
871
+ resolveSessionName,
872
+ stopAllSessions,
873
+ };
874
+
875
+ function signalPid(pid, signal) {
876
+ if (!Number.isInteger(pid) || pid <= 0 || !isPidAlive(pid)) {
877
+ return false;
878
+ }
879
+
880
+ try {
881
+ process.kill(pid, signal);
882
+ return true;
883
+ } catch {
884
+ return false;
885
+ }
694
886
  }
695
887
 
696
- function readAllSessionStatuses() {
697
- return listSessionNames().map((sessionName) => readSessionStatus(sessionName));
888
+ function signalProcessTree(rootPid, signal) {
889
+ if (!Number.isInteger(rootPid) || rootPid <= 0) {
890
+ return 0;
891
+ }
892
+
893
+ let delivered = 0;
894
+ for (const process of getChildProcesses(rootPid)) {
895
+ if (signalPid(process.pid, signal)) {
896
+ delivered += 1;
897
+ }
898
+ }
899
+
900
+ if (signalPid(rootPid, signal)) {
901
+ delivered += 1;
902
+ }
903
+
904
+ return delivered;
698
905
  }
699
906
 
700
- module.exports = {
701
- SeatProcess,
702
- formatCommand,
703
- readAllSessionStatuses,
704
- readSessionStatus,
705
- resolveProgramTokens,
706
- resolveSessionName,
707
- stopSession,
708
- stopSessions,
709
- };
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
+ 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;
927
+ }
928
+
929
+ function clearStaleStopRequest(stopPath, startedAtMs) {
930
+ const request = readJson(stopPath, null);
931
+ if (!request?.requestedAt) {
932
+ return;
933
+ }
934
+
935
+ const requestedAtMs = Date.parse(request.requestedAt);
936
+ if (Number.isFinite(requestedAtMs) && requestedAtMs <= startedAtMs) {
937
+ fs.rmSync(stopPath, { force: true });
938
+ }
939
+ }
940
+
941
+ function selectVisibleStopRequest(requestedAt, seats) {
942
+ if (!requestedAt) {
943
+ return null;
944
+ }
945
+
946
+ const requestedAtMs = Date.parse(requestedAt);
947
+ if (!Number.isFinite(requestedAtMs)) {
948
+ return null;
949
+ }
950
+
951
+ const newestStartedAtMs = seats
952
+ .map((seat) => Date.parse(seat.startedAt || ""))
953
+ .filter((value) => Number.isFinite(value))
954
+ .sort((left, right) => right - left)[0];
955
+
956
+ if (Number.isFinite(newestStartedAtMs) && requestedAtMs <= newestStartedAtMs) {
957
+ return null;
958
+ }
959
+
960
+ return requestedAt;
961
+ }