muuuuse 1.4.0 → 1.4.2

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