muuuuse 1.5.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/agents.js +253 -30
  3. package/src/runtime.js +321 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muuuuse",
3
- "version": "1.5.0",
3
+ "version": "2.1.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
@@ -12,9 +12,11 @@ const {
12
12
  } = require("./util");
13
13
 
14
14
  const CODEX_ROOT = path.join(os.homedir(), ".codex", "sessions");
15
+ const CODEX_SNAPSHOT_ROOT = path.join(os.homedir(), ".codex", "shell_snapshots");
15
16
  const CLAUDE_ROOT = path.join(os.homedir(), ".claude", "projects");
16
17
  const GEMINI_ROOT = path.join(os.homedir(), ".gemini", "tmp");
17
18
  const SESSION_START_EARLY_TOLERANCE_MS = 2 * 1000;
19
+ const STRICT_SINGLE_CANDIDATE_EARLY_TOLERANCE_MS = 250;
18
20
 
19
21
  function walkFiles(rootPath, predicate, results = []) {
20
22
  try {
@@ -51,7 +53,11 @@ function buildDetectedAgent(type, process) {
51
53
  }
52
54
 
53
55
  function detectAgent(processes) {
54
- const ordered = [...processes].sort((left, right) => left.elapsedSeconds - right.elapsedSeconds);
56
+ const ordered = [...processes].sort((left, right) => (
57
+ (left.depth ?? Number.MAX_SAFE_INTEGER) - (right.depth ?? Number.MAX_SAFE_INTEGER) ||
58
+ right.elapsedSeconds - left.elapsedSeconds ||
59
+ left.pid - right.pid
60
+ ));
55
61
  for (const process of ordered) {
56
62
  if (commandMatches(process.args, "codex")) {
57
63
  return buildDetectedAgent("codex", process);
@@ -93,6 +99,33 @@ function readFirstLines(filePath, maxLines = 20) {
93
99
  }
94
100
  }
95
101
 
102
+ function sortSessionCandidates(candidates) {
103
+ return candidates
104
+ .slice()
105
+ .sort((left, right) => {
106
+ const leftDiff = Number.isFinite(left.diffMs) ? left.diffMs : Number.MAX_SAFE_INTEGER;
107
+ const rightDiff = Number.isFinite(right.diffMs) ? right.diffMs : Number.MAX_SAFE_INTEGER;
108
+ return (
109
+ leftDiff - rightDiff ||
110
+ right.startedAtMs - left.startedAtMs ||
111
+ right.mtimeMs - left.mtimeMs ||
112
+ left.path.localeCompare(right.path)
113
+ );
114
+ });
115
+ }
116
+
117
+ function annotateSessionCandidates(candidates, processStartedAtMs) {
118
+ return candidates.map((candidate) => ({
119
+ ...candidate,
120
+ diffMs: Number.isFinite(processStartedAtMs) && Number.isFinite(candidate.startedAtMs)
121
+ ? Math.abs(candidate.startedAtMs - processStartedAtMs)
122
+ : Number.POSITIVE_INFINITY,
123
+ relativeStartMs: Number.isFinite(processStartedAtMs) && Number.isFinite(candidate.startedAtMs)
124
+ ? candidate.startedAtMs - processStartedAtMs
125
+ : Number.NaN,
126
+ }));
127
+ }
128
+
96
129
  function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs) {
97
130
  const cwdMatches = candidates.filter((candidate) => candidate.cwd === currentPath);
98
131
  if (cwdMatches.length === 0) {
@@ -107,12 +140,7 @@ function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs)
107
140
  return null;
108
141
  }
109
142
 
110
- const preciseMatches = cwdMatches
111
- .map((candidate) => ({
112
- ...candidate,
113
- diffMs: Math.abs(candidate.startedAtMs - processStartedAtMs),
114
- relativeStartMs: candidate.startedAtMs - processStartedAtMs,
115
- }))
143
+ const preciseMatches = annotateSessionCandidates(cwdMatches, processStartedAtMs)
116
144
  .filter((candidate) => (
117
145
  Number.isFinite(candidate.diffMs) &&
118
146
  Number.isFinite(candidate.relativeStartMs) &&
@@ -128,29 +156,98 @@ function selectSessionCandidatePath(candidates, currentPath, processStartedAtMs)
128
156
  return null;
129
157
  }
130
158
 
131
- function listOpenFilePaths(pid, rootPath) {
132
- if (!Number.isInteger(pid) || pid <= 0) {
133
- return [];
159
+ function readCodexSeatClaim(sessionId) {
160
+ if (!sessionId) {
161
+ return null;
134
162
  }
135
163
 
136
- const fdRoot = `/proc/${pid}/fd`;
164
+ const snapshotPath = path.join(CODEX_SNAPSHOT_ROOT, `${sessionId}.sh`);
137
165
  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));
166
+ const text = fs.readFileSync(snapshotPath, "utf8");
167
+ const seatMatch = text.match(/declare -x MUUUUSE_SEAT="([^"]+)"/);
168
+ const sessionMatch = text.match(/declare -x MUUUUSE_SESSION="([^"]+)"/);
169
+ if (!seatMatch || !sessionMatch) {
170
+ return null;
171
+ }
149
172
 
150
- return [...new Set(openPaths)];
173
+ return {
174
+ seatId: seatMatch[1],
175
+ sessionName: sessionMatch[1],
176
+ };
151
177
  } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function selectClaimedCodexCandidatePath(candidates, options = {}) {
183
+ const seatId = options.seatId == null ? null : String(options.seatId);
184
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
185
+ if (!seatId || !sessionName || candidates.length <= 1) {
186
+ return null;
187
+ }
188
+
189
+ const annotated = candidates.map((candidate) => ({
190
+ ...candidate,
191
+ claim: readCodexSeatClaim(candidate.sessionId),
192
+ }));
193
+
194
+ const exactMatches = annotated.filter((candidate) => (
195
+ candidate.claim?.seatId === seatId &&
196
+ candidate.claim?.sessionName === sessionName
197
+ ));
198
+ if (exactMatches.length === 1) {
199
+ return exactMatches[0].path;
200
+ }
201
+
202
+ const otherSeatClaims = annotated.filter((candidate) => (
203
+ candidate.claim?.sessionName === sessionName &&
204
+ candidate.claim?.seatId !== seatId
205
+ ));
206
+ if (otherSeatClaims.length === 0) {
207
+ return null;
208
+ }
209
+
210
+ const foreignPaths = new Set(otherSeatClaims.map((candidate) => candidate.path));
211
+ const remaining = annotated.filter((candidate) => !foreignPaths.has(candidate.path));
212
+ if (remaining.length === 1) {
213
+ return remaining[0].path;
214
+ }
215
+
216
+ return null;
217
+ }
218
+
219
+ function listOpenFilePathsForPids(pids, rootPath) {
220
+ const normalizedPids = [...new Set(
221
+ (Array.isArray(pids) ? pids : [pids])
222
+ .map((pid) => Number.parseInt(pid, 10))
223
+ .filter((pid) => Number.isInteger(pid) && pid > 0)
224
+ )];
225
+ if (normalizedPids.length === 0) {
152
226
  return [];
153
227
  }
228
+
229
+ const rootPrefix = path.resolve(rootPath);
230
+ const openPaths = new Set();
231
+
232
+ for (const pid of normalizedPids) {
233
+ const fdRoot = `/proc/${pid}/fd`;
234
+ try {
235
+ for (const entry of fs.readdirSync(fdRoot)) {
236
+ try {
237
+ const resolved = fs.realpathSync(path.join(fdRoot, entry));
238
+ if (typeof resolved === "string" && resolved.startsWith(rootPrefix)) {
239
+ openPaths.add(resolved);
240
+ }
241
+ } catch {
242
+ // Ignore descriptors that disappear while we are inspecting them.
243
+ }
244
+ }
245
+ } catch {
246
+ // Ignore pids that have already exited.
247
+ }
248
+ }
249
+
250
+ return [...openPaths];
154
251
  }
155
252
 
156
253
  function selectLiveSessionCandidatePath(candidates, currentPath, captureSinceMs = null) {
@@ -173,8 +270,8 @@ function selectLiveSessionCandidatePath(candidates, currentPath, captureSinceMs
173
270
  return ranked[0]?.path || null;
174
271
  }
175
272
 
176
- function readOpenSessionCandidates(pid, rootPath, reader) {
177
- return listOpenFilePaths(pid, rootPath)
273
+ function readOpenSessionCandidates(pids, rootPath, reader) {
274
+ return listOpenFilePathsForPids(pids, rootPath)
178
275
  .map((filePath) => reader(filePath))
179
276
  .filter((candidate) => candidate !== null);
180
277
  }
@@ -195,6 +292,7 @@ function readCodexCandidate(filePath) {
195
292
  path: filePath,
196
293
  cwd: entry.payload.cwd,
197
294
  isSubagent: Boolean(entry.payload?.source?.subagent),
295
+ sessionId: entry.payload.id || null,
198
296
  startedAtMs: Date.parse(entry.payload.timestamp),
199
297
  mtimeMs: fs.statSync(filePath).mtimeMs,
200
298
  };
@@ -203,9 +301,131 @@ function readCodexCandidate(filePath) {
203
301
  }
204
302
  }
205
303
 
304
+ function rankCodexCandidates(candidates, processStartedAtMs) {
305
+ return sortSessionCandidates(annotateSessionCandidates(candidates, processStartedAtMs));
306
+ }
307
+
308
+ function selectExactClaimedCodexCandidate(candidates, options = {}, processStartedAtMs = null) {
309
+ const seatId = options.seatId == null ? null : String(options.seatId);
310
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
311
+ if (!seatId || !sessionName) {
312
+ return null;
313
+ }
314
+
315
+ const exactMatches = rankCodexCandidates(
316
+ candidates.filter((candidate) => {
317
+ const claim = readCodexSeatClaim(candidate.sessionId);
318
+ return claim?.seatId === seatId && claim?.sessionName === sessionName;
319
+ }),
320
+ processStartedAtMs
321
+ );
322
+
323
+ return exactMatches[0]?.path || null;
324
+ }
325
+
326
+ function filterForeignClaimedCodexCandidates(candidates, options = {}) {
327
+ const seatId = options.seatId == null ? null : String(options.seatId);
328
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
329
+ if (!seatId || !sessionName) {
330
+ return candidates.slice();
331
+ }
332
+
333
+ return candidates.filter((candidate) => {
334
+ const claim = readCodexSeatClaim(candidate.sessionId);
335
+ return !(claim?.sessionName === sessionName && claim?.seatId && claim.seatId !== seatId);
336
+ });
337
+ }
338
+
339
+ function selectStrictSingleCodexCandidatePath(candidates, processStartedAtMs) {
340
+ if (candidates.length !== 1 || !Number.isFinite(processStartedAtMs)) {
341
+ return null;
342
+ }
343
+
344
+ const [candidate] = annotateSessionCandidates(candidates, processStartedAtMs);
345
+ if (!Number.isFinite(candidate.relativeStartMs)) {
346
+ return null;
347
+ }
348
+
349
+ if (
350
+ candidate.relativeStartMs < -STRICT_SINGLE_CANDIDATE_EARLY_TOLERANCE_MS ||
351
+ candidate.relativeStartMs > SESSION_MATCH_WINDOW_MS
352
+ ) {
353
+ return null;
354
+ }
355
+
356
+ return candidate.path;
357
+ }
358
+
359
+ function selectCodexCandidatePath(candidates, currentPath, processStartedAtMs, options = {}) {
360
+ const cwdMatches = candidates.filter((candidate) => candidate.cwd === currentPath);
361
+ if (cwdMatches.length === 0) {
362
+ return null;
363
+ }
364
+
365
+ const seatId = options.seatId == null ? null : String(options.seatId);
366
+ const sessionName = typeof options.sessionName === "string" ? options.sessionName : null;
367
+ const exactClaimPath = selectExactClaimedCodexCandidate(cwdMatches, options, processStartedAtMs);
368
+ if (exactClaimPath) {
369
+ return exactClaimPath;
370
+ }
371
+
372
+ const foreignClaimsPresent = Boolean(
373
+ seatId &&
374
+ sessionName &&
375
+ cwdMatches.some((candidate) => {
376
+ const claim = readCodexSeatClaim(candidate.sessionId);
377
+ return claim?.sessionName === sessionName && claim?.seatId && claim.seatId !== seatId;
378
+ })
379
+ );
380
+ const allowedMatches = filterForeignClaimedCodexCandidates(cwdMatches, options);
381
+ if (allowedMatches.length === 0) {
382
+ return null;
383
+ }
384
+
385
+ if (!Number.isFinite(processStartedAtMs)) {
386
+ return allowedMatches.length === 1 ? allowedMatches[0].path : null;
387
+ }
388
+
389
+ const preciseMatches = rankCodexCandidates(
390
+ allowedMatches.filter((candidate) => {
391
+ const annotated = annotateSessionCandidates([candidate], processStartedAtMs)[0];
392
+ return (
393
+ Number.isFinite(annotated.diffMs) &&
394
+ Number.isFinite(annotated.relativeStartMs) &&
395
+ annotated.relativeStartMs >= -SESSION_START_EARLY_TOLERANCE_MS &&
396
+ annotated.relativeStartMs <= SESSION_MATCH_WINDOW_MS
397
+ );
398
+ }),
399
+ processStartedAtMs
400
+ );
401
+
402
+ const preciseClaimPath = selectClaimedCodexCandidatePath(preciseMatches, options);
403
+ if (preciseClaimPath) {
404
+ return preciseClaimPath;
405
+ }
406
+
407
+ const pairedSeatSelection = seatId && sessionName;
408
+ if (pairedSeatSelection && options.allowUnclaimedSingleCandidate === false && !foreignClaimsPresent) {
409
+ return null;
410
+ }
411
+
412
+ if (preciseMatches.length === 1) {
413
+ return selectStrictSingleCodexCandidatePath(preciseMatches, processStartedAtMs);
414
+ }
415
+
416
+ if (allowedMatches.length === 1) {
417
+ return selectStrictSingleCodexCandidatePath(allowedMatches, processStartedAtMs);
418
+ }
419
+
420
+ return null;
421
+ }
422
+
206
423
  function selectCodexSessionFile(currentPath, processStartedAtMs, options = {}) {
207
- const liveCandidates = readOpenSessionCandidates(options.pid, CODEX_ROOT, readCodexCandidate);
208
- const livePath = selectLiveSessionCandidatePath(liveCandidates, currentPath, options.captureSinceMs);
424
+ const liveCandidates = readOpenSessionCandidates(options.pids ?? options.pid, CODEX_ROOT, readCodexCandidate);
425
+ const livePath = selectCodexCandidatePath(liveCandidates, currentPath, processStartedAtMs, {
426
+ ...options,
427
+ allowUnclaimedSingleCandidate: true,
428
+ });
209
429
  if (livePath) {
210
430
  return livePath;
211
431
  }
@@ -214,7 +434,10 @@ function selectCodexSessionFile(currentPath, processStartedAtMs, options = {}) {
214
434
  .map((filePath) => readCodexCandidate(filePath))
215
435
  .filter((candidate) => candidate !== null);
216
436
 
217
- return selectSessionCandidatePath(candidates, currentPath, processStartedAtMs);
437
+ return selectCodexCandidatePath(candidates, currentPath, processStartedAtMs, {
438
+ ...options,
439
+ allowUnclaimedSingleCandidate: false,
440
+ });
218
441
  }
219
442
 
220
443
  function extractCodexAssistantText(content) {
@@ -311,7 +534,7 @@ function readClaudeCandidate(filePath) {
311
534
  }
312
535
 
313
536
  function selectClaudeSessionFile(currentPath, processStartedAtMs, options = {}) {
314
- const liveCandidates = readOpenSessionCandidates(options.pid, CLAUDE_ROOT, readClaudeCandidate);
537
+ const liveCandidates = readOpenSessionCandidates(options.pids ?? options.pid, CLAUDE_ROOT, readClaudeCandidate);
315
538
  const livePath = selectLiveSessionCandidatePath(liveCandidates, currentPath, options.captureSinceMs);
316
539
  if (livePath) {
317
540
  return livePath;
@@ -397,7 +620,7 @@ function readGeminiCandidate(filePath) {
397
620
 
398
621
  function selectGeminiSessionFile(currentPath, processStartedAtMs, options = {}) {
399
622
  const projectHash = createHash("sha256").update(currentPath).digest("hex");
400
- const liveCandidates = readOpenSessionCandidates(options.pid, GEMINI_ROOT, readGeminiCandidate)
623
+ const liveCandidates = readOpenSessionCandidates(options.pids ?? options.pid, GEMINI_ROOT, readGeminiCandidate)
401
624
  .filter((candidate) => candidate.projectHash === projectHash);
402
625
  const livePath = selectLiveSessionCandidatePath(liveCandidates, projectHash, options.captureSinceMs);
403
626
  if (livePath) {
package/src/runtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require("node:fs");
2
2
  const { execFileSync } = require("node:child_process");
3
+ const path = require("node:path");
3
4
  const pty = require("node-pty");
4
5
 
5
6
  const {
@@ -36,8 +37,19 @@ const {
36
37
 
37
38
  const TYPE_DELAY_MS = 70;
38
39
  const MIRROR_SUPPRESSION_WINDOW_MS = 30 * 1000;
40
+ const PENDING_RELAY_CONTEXT_TTL_MS = 2 * 60 * 1000;
41
+ const EMITTED_ANSWER_TTL_MS = 5 * 60 * 1000;
39
42
  const MAX_RECENT_INBOUND_RELAYS = 12;
43
+ const MAX_RECENT_EMITTED_ANSWERS = 48;
44
+ const MAX_RELAY_CHAIN_HOP = 1;
40
45
  const STOP_FORCE_KILL_MS = 1200;
46
+ const SEAT_JOIN_WAIT_MS = 3000;
47
+ const SEAT_JOIN_POLL_MS = 60;
48
+ const CHILD_ENV_DROP_KEYS = [
49
+ "CODEX_CI",
50
+ "CODEX_MANAGED_BY_NPM",
51
+ "CODEX_THREAD_ID",
52
+ ];
41
53
 
42
54
  function resolveShell() {
43
55
  const shell = String(process.env.SHELL || "").trim();
@@ -52,16 +64,139 @@ function resolveShellArgs(shellPath) {
52
64
  return [];
53
65
  }
54
66
 
55
- function resolveChildTerm() {
56
- const inherited = String(process.env.TERM || "").trim();
67
+ function resolveChildTerm(sourceEnv = process.env) {
68
+ const inherited = String(sourceEnv.TERM || "").trim();
57
69
  if (inherited && inherited.toLowerCase() !== "dumb") {
58
70
  return inherited;
59
71
  }
60
72
  return "xterm-256color";
61
73
  }
62
74
 
63
- function resolveSessionName(currentPath = process.cwd()) {
64
- return getDefaultSessionName(currentPath);
75
+ function sanitizeChildPath(pathValue, homeDir) {
76
+ const arg0Root = path.join(homeDir, ".codex", "tmp", "arg0");
77
+ const entries = String(pathValue || "")
78
+ .split(path.delimiter)
79
+ .filter(Boolean)
80
+ .filter((entry) => {
81
+ const resolved = path.resolve(entry);
82
+ return resolved !== arg0Root && !resolved.startsWith(`${arg0Root}${path.sep}`);
83
+ });
84
+
85
+ return entries.join(path.delimiter);
86
+ }
87
+
88
+ function buildChildEnv(seatId, sessionName, cwd, baseEnv = process.env) {
89
+ const env = { ...baseEnv };
90
+ for (const key of CHILD_ENV_DROP_KEYS) {
91
+ delete env[key];
92
+ }
93
+
94
+ const homeDir = String(env.HOME || "").trim() || process.env.HOME || "/root";
95
+ env.PATH = sanitizeChildPath(env.PATH, homeDir);
96
+ env.PWD = cwd;
97
+ env.TERM = resolveChildTerm(baseEnv);
98
+ env.MUUUUSE_SEAT = String(seatId);
99
+ env.MUUUUSE_SESSION = sessionName;
100
+ return env;
101
+ }
102
+
103
+ function normalizeWorkingPath(currentPath = process.cwd()) {
104
+ try {
105
+ return fs.realpathSync(currentPath);
106
+ } catch {
107
+ return path.resolve(currentPath);
108
+ }
109
+ }
110
+
111
+ function matchesWorkingPath(leftPath, rightPath) {
112
+ if (!leftPath || !rightPath) {
113
+ return false;
114
+ }
115
+
116
+ return normalizeWorkingPath(leftPath) === normalizeWorkingPath(rightPath);
117
+ }
118
+
119
+ function createSessionName(currentPath = process.cwd()) {
120
+ return `${getDefaultSessionName(currentPath)}-${createId(6)}`;
121
+ }
122
+
123
+ function sleepSync(ms) {
124
+ if (!Number.isFinite(ms) || ms <= 0) {
125
+ return;
126
+ }
127
+
128
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
129
+ }
130
+
131
+ function findJoinableSessionName(currentPath = process.cwd()) {
132
+ const candidates = listSessionNames()
133
+ .map((sessionName) => {
134
+ const sessionPaths = getSessionPaths(sessionName);
135
+ const controller = readJson(sessionPaths.controllerPath, null);
136
+ const seat1Paths = getSeatPaths(sessionName, 1);
137
+ const seat2Paths = getSeatPaths(sessionName, 2);
138
+ const seat1Meta = readJson(seat1Paths.metaPath, null);
139
+ const seat1Status = readJson(seat1Paths.statusPath, null);
140
+ const seat2Meta = readJson(seat2Paths.metaPath, null);
141
+ const seat2Status = readJson(seat2Paths.statusPath, null);
142
+ const stopRequest = readJson(sessionPaths.stopPath, null);
143
+
144
+ const cwd = controller?.cwd || seat1Status?.cwd || seat1Meta?.cwd || seat2Status?.cwd || seat2Meta?.cwd || null;
145
+ if (!matchesWorkingPath(cwd, currentPath)) {
146
+ return null;
147
+ }
148
+
149
+ const seat1WrapperPid = seat1Status?.pid || seat1Meta?.pid || null;
150
+ const seat1ChildPid = seat1Status?.childPid || seat1Meta?.childPid || null;
151
+ const seat2WrapperPid = seat2Status?.pid || seat2Meta?.pid || null;
152
+ const seat2ChildPid = seat2Status?.childPid || seat2Meta?.childPid || null;
153
+ const seat1Live = isPidAlive(seat1WrapperPid) || isPidAlive(seat1ChildPid);
154
+ const seat2Live = isPidAlive(seat2WrapperPid) || isPidAlive(seat2ChildPid);
155
+ const stopRequestedAtMs = Date.parse(stopRequest?.requestedAt || "");
156
+ const createdAtMs = Date.parse(controller?.createdAt || seat1Meta?.startedAt || seat1Status?.updatedAt || "");
157
+
158
+ if (!seat1Live || seat2Live) {
159
+ return null;
160
+ }
161
+
162
+ if (Number.isFinite(stopRequestedAtMs) && Number.isFinite(createdAtMs) && stopRequestedAtMs > createdAtMs) {
163
+ return null;
164
+ }
165
+
166
+ return {
167
+ sessionName,
168
+ createdAtMs,
169
+ };
170
+ })
171
+ .filter((entry) => entry !== null)
172
+ .sort((left, right) => right.createdAtMs - left.createdAtMs);
173
+
174
+ return candidates[0]?.sessionName || null;
175
+ }
176
+
177
+ function waitForJoinableSessionName(currentPath = process.cwd(), timeoutMs = SEAT_JOIN_WAIT_MS) {
178
+ const deadline = Date.now() + timeoutMs;
179
+ while (Date.now() <= deadline) {
180
+ const sessionName = findJoinableSessionName(currentPath);
181
+ if (sessionName) {
182
+ return sessionName;
183
+ }
184
+ sleepSync(SEAT_JOIN_POLL_MS);
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ function resolveSessionName(currentPath = process.cwd(), seatId = 1) {
191
+ if (seatId === 1) {
192
+ return createSessionName(currentPath);
193
+ }
194
+
195
+ if (seatId === 2) {
196
+ return waitForJoinableSessionName(currentPath);
197
+ }
198
+
199
+ return createSessionName(currentPath);
65
200
  }
66
201
 
67
202
  function parseAnswerEntries(text) {
@@ -121,20 +256,25 @@ function getChildProcesses(rootPid) {
121
256
  .filter((entry) => entry !== null);
122
257
 
123
258
  const descendants = [];
124
- const queue = [rootPid];
125
- const seen = new Set(queue);
259
+ const queue = [{ pid: rootPid, depth: 0 }];
260
+ const seen = new Set([rootPid]);
126
261
 
127
262
  while (queue.length > 0) {
128
- const parentPid = queue.shift();
263
+ const current = queue.shift();
264
+ const parentPid = current.pid;
129
265
  for (const process of processes) {
130
266
  if (process.ppid !== parentPid || seen.has(process.pid)) {
131
267
  continue;
132
268
  }
133
269
  seen.add(process.pid);
134
- queue.push(process.pid);
270
+ queue.push({
271
+ pid: process.pid,
272
+ depth: current.depth + 1,
273
+ });
135
274
  descendants.push({
136
275
  ...process,
137
276
  cwd: readProcessCwd(process.pid),
277
+ depth: current.depth + 1,
138
278
  });
139
279
  }
140
280
  }
@@ -145,14 +285,40 @@ function getChildProcesses(rootPid) {
145
285
  }
146
286
  }
147
287
 
148
- function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, processStartedAtMs) {
288
+ function getProcessFamilyPids(processes, rootPid) {
289
+ if (!Number.isInteger(rootPid) || rootPid <= 0) {
290
+ return [];
291
+ }
292
+
293
+ const related = new Set([rootPid]);
294
+ const queue = [rootPid];
295
+
296
+ while (queue.length > 0) {
297
+ const parentPid = queue.shift();
298
+ for (const process of processes) {
299
+ if (process.ppid !== parentPid || related.has(process.pid)) {
300
+ continue;
301
+ }
302
+
303
+ related.add(process.pid);
304
+ queue.push(process.pid);
305
+ }
306
+ }
307
+
308
+ return [...related];
309
+ }
310
+
311
+ function resolveSessionFile(agentType, agentPid, currentPath, captureSinceMs, processStartedAtMs, seatContext = {}) {
149
312
  if (!currentPath) {
150
313
  return null;
151
314
  }
152
315
 
153
316
  const options = {
154
317
  pid: agentPid,
318
+ pids: seatContext.agentPids,
155
319
  captureSinceMs,
320
+ seatId: seatContext.seatId,
321
+ sessionName: seatContext.sessionName,
156
322
  };
157
323
 
158
324
  if (agentType === "codex") {
@@ -192,6 +358,8 @@ function buildAnswerSignaturePayload(sessionName, challenge, entry) {
192
358
  type: "muuuuse_answer",
193
359
  sessionName,
194
360
  challenge,
361
+ chainId: entry.chainId,
362
+ hop: entry.hop,
195
363
  id: entry.id,
196
364
  seatId: entry.seatId,
197
365
  origin: entry.origin,
@@ -267,8 +435,11 @@ class ArmedSeat {
267
435
  constructor(options) {
268
436
  this.seatId = options.seatId;
269
437
  this.partnerSeatId = options.seatId === 1 ? 2 : 1;
270
- this.cwd = options.cwd;
271
- this.sessionName = resolveSessionName(this.cwd);
438
+ this.cwd = normalizeWorkingPath(options.cwd);
439
+ this.sessionName = resolveSessionName(this.cwd, this.seatId);
440
+ if (!this.sessionName) {
441
+ throw new Error("No armed `muuuuse 1` seat is waiting in this cwd. Run `muuuuse 1` first.");
442
+ }
272
443
  this.sessionPaths = getSessionPaths(this.sessionName);
273
444
  this.paths = getSeatPaths(this.sessionName, this.seatId);
274
445
  this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
@@ -286,7 +457,10 @@ class ArmedSeat {
286
457
  this.resizeCleanup = null;
287
458
  this.forceKillTimer = null;
288
459
  this.identity = null;
460
+ this.lastUserInputAtMs = 0;
461
+ this.pendingInboundContext = null;
289
462
  this.recentInboundRelays = [];
463
+ this.recentEmittedAnswers = [];
290
464
  this.trustState = {
291
465
  challenge: null,
292
466
  peerPublicKey: null,
@@ -306,6 +480,20 @@ class ArmedSeat {
306
480
  };
307
481
  }
308
482
 
483
+ writeController(extra = {}) {
484
+ const current = readJson(this.sessionPaths.controllerPath, {});
485
+ writeJson(this.sessionPaths.controllerPath, {
486
+ sessionName: this.sessionName,
487
+ cwd: this.cwd,
488
+ createdAt: current.createdAt || this.startedAt,
489
+ updatedAt: new Date().toISOString(),
490
+ seat1Pid: this.seatId === 1 ? process.pid : current.seat1Pid || null,
491
+ seat2Pid: this.seatId === 2 ? process.pid : current.seat2Pid || null,
492
+ pid: this.seatId === 1 ? process.pid : current.pid || null,
493
+ ...extra,
494
+ });
495
+ }
496
+
309
497
  log(message) {
310
498
  process.stderr.write(`${message}\n`);
311
499
  }
@@ -509,20 +697,17 @@ class ArmedSeat {
509
697
  fs.rmSync(this.paths.pipePath, { force: true });
510
698
  clearStaleStopRequest(this.sessionPaths.stopPath, this.startedAtMs);
511
699
  this.initializeTrustMaterial();
700
+ this.writeController();
512
701
 
513
702
  const shell = resolveShell();
514
703
  const shellArgs = resolveShellArgs(shell);
704
+ const childEnv = buildChildEnv(this.seatId, this.sessionName, this.cwd);
515
705
  this.child = pty.spawn(shell, shellArgs, {
516
706
  cols: process.stdout.columns || 120,
517
707
  rows: process.stdout.rows || 36,
518
708
  cwd: this.cwd,
519
- env: {
520
- ...process.env,
521
- TERM: resolveChildTerm(),
522
- MUUUUSE_SEAT: String(this.seatId),
523
- MUUUUSE_SESSION: this.sessionName,
524
- },
525
- name: resolveChildTerm(),
709
+ env: childEnv,
710
+ name: childEnv.TERM,
526
711
  });
527
712
 
528
713
  this.childPid = this.child.pid;
@@ -547,6 +732,8 @@ class ArmedSeat {
547
732
 
548
733
  installStdinProxy() {
549
734
  const handleData = (chunk) => {
735
+ this.lastUserInputAtMs = Date.now();
736
+ this.pendingInboundContext = null;
550
737
  if (!this.child) {
551
738
  return;
552
739
  }
@@ -687,6 +874,8 @@ class ArmedSeat {
687
874
 
688
875
  const payload = sanitizeRelayText(entry.text);
689
876
  const signaturePayload = buildAnswerSignaturePayload(this.sessionName, this.trustState.challenge, {
877
+ chainId: entry.chainId || entry.id,
878
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
690
879
  id: entry.id,
691
880
  seatId: entry.seatId,
692
881
  origin: entry.origin || "unknown",
@@ -718,6 +907,14 @@ class ArmedSeat {
718
907
  return;
719
908
  }
720
909
 
910
+ const deliveredAtMs = Date.now();
911
+ this.pendingInboundContext = {
912
+ chainId: entry.chainId || entry.id,
913
+ deliveredAtMs,
914
+ expiresAtMs: deliveredAtMs + PENDING_RELAY_CONTEXT_TTL_MS,
915
+ hop: Number.isInteger(entry.hop) ? entry.hop : 0,
916
+ relayUsed: false,
917
+ };
721
918
  this.relayCount += 1;
722
919
  this.rememberInboundRelay(payload);
723
920
  this.log(`[${this.partnerSeatId} -> ${this.seatId}] ${previewText(payload)}`);
@@ -749,6 +946,37 @@ class ArmedSeat {
749
946
  );
750
947
  }
751
948
 
949
+ pruneRecentEmittedAnswers(now = Date.now()) {
950
+ this.recentEmittedAnswers = this.recentEmittedAnswers.filter(
951
+ (entry) => now - entry.timestampMs <= EMITTED_ANSWER_TTL_MS
952
+ );
953
+ }
954
+
955
+ hasRecentEmittedAnswer(answerKey) {
956
+ if (!answerKey) {
957
+ return false;
958
+ }
959
+
960
+ this.pruneRecentEmittedAnswers();
961
+ return this.recentEmittedAnswers.some((entry) => entry.key === answerKey);
962
+ }
963
+
964
+ rememberEmittedAnswer(answerKey) {
965
+ if (!answerKey) {
966
+ return;
967
+ }
968
+
969
+ this.pruneRecentEmittedAnswers();
970
+ this.recentEmittedAnswers.push({
971
+ key: answerKey,
972
+ timestampMs: Date.now(),
973
+ });
974
+
975
+ if (this.recentEmittedAnswers.length > MAX_RECENT_EMITTED_ANSWERS) {
976
+ this.recentEmittedAnswers = this.recentEmittedAnswers.slice(-MAX_RECENT_EMITTED_ANSWERS);
977
+ }
978
+ }
979
+
752
980
  takeMirroredInboundRelay(payload) {
753
981
  const normalized = sanitizeRelayText(payload);
754
982
  if (!normalized) {
@@ -766,8 +994,28 @@ class ArmedSeat {
766
994
  return match;
767
995
  }
768
996
 
997
+ getPendingInboundContext() {
998
+ const context = this.pendingInboundContext;
999
+ if (!context) {
1000
+ return null;
1001
+ }
1002
+
1003
+ if (context.expiresAtMs <= Date.now()) {
1004
+ this.pendingInboundContext = null;
1005
+ return null;
1006
+ }
1007
+
1008
+ if (this.lastUserInputAtMs > context.deliveredAtMs) {
1009
+ this.pendingInboundContext = null;
1010
+ return null;
1011
+ }
1012
+
1013
+ return context;
1014
+ }
1015
+
769
1016
  collectLiveAnswers() {
770
- const detectedAgent = detectAgent(getChildProcesses(this.childPid));
1017
+ const childProcesses = getChildProcesses(this.childPid);
1018
+ const detectedAgent = detectAgent(childProcesses);
771
1019
  if (!detectedAgent) {
772
1020
  this.liveState = {
773
1021
  type: null,
@@ -813,19 +1061,24 @@ class ArmedSeat {
813
1061
  };
814
1062
  }
815
1063
 
816
- if (!this.liveState.sessionFile) {
817
- this.liveState.sessionFile = resolveSessionFile(
818
- detectedAgent.type,
819
- detectedAgent.pid,
820
- currentPath,
821
- this.liveState.captureSinceMs,
822
- detectedAgent.processStartedAtMs
823
- );
824
-
825
- if (this.liveState.sessionFile) {
826
- this.liveState.offset = 0;
827
- this.liveState.lastMessageId = null;
1064
+ const agentPids = getProcessFamilyPids(childProcesses, detectedAgent.pid);
1065
+ const resolvedSessionFile = resolveSessionFile(
1066
+ detectedAgent.type,
1067
+ detectedAgent.pid,
1068
+ currentPath,
1069
+ this.liveState.captureSinceMs,
1070
+ detectedAgent.processStartedAtMs,
1071
+ {
1072
+ agentPids,
1073
+ seatId: this.seatId,
1074
+ sessionName: this.sessionName,
828
1075
  }
1076
+ );
1077
+
1078
+ if (resolvedSessionFile && resolvedSessionFile !== this.liveState.sessionFile) {
1079
+ this.liveState.sessionFile = resolvedSessionFile;
1080
+ this.liveState.offset = 0;
1081
+ this.liveState.lastMessageId = null;
829
1082
  }
830
1083
 
831
1084
  if (!this.liveState.sessionFile) {
@@ -895,19 +1148,39 @@ class ArmedSeat {
895
1148
  return;
896
1149
  }
897
1150
 
1151
+ const answerKey = buildAnswerKey(entry, payload);
1152
+ if (this.hasRecentEmittedAnswer(answerKey)) {
1153
+ this.log(`[${this.seatId}] suppressed duplicate final answer: ${previewText(payload)}`);
1154
+ return;
1155
+ }
1156
+
898
1157
  const mirroredInbound = this.takeMirroredInboundRelay(payload);
899
1158
  if (mirroredInbound) {
900
1159
  this.log(`[${this.seatId}] suppressed mirrored relay: ${previewText(payload)}`);
901
1160
  return;
902
1161
  }
903
1162
 
1163
+ const pendingInboundContext = this.getPendingInboundContext();
1164
+ if (pendingInboundContext && pendingInboundContext.hop >= MAX_RELAY_CHAIN_HOP) {
1165
+ this.log(`[${this.seatId}] suppressed relay loop: ${previewText(payload)}`);
1166
+ return;
1167
+ }
1168
+
1169
+ if (pendingInboundContext?.relayUsed) {
1170
+ this.log(`[${this.seatId}] suppressed extra queued relay output: ${previewText(payload)}`);
1171
+ return;
1172
+ }
1173
+
1174
+ const entryId = entry.id || createId(12);
904
1175
  const signedEntry = {
905
- id: entry.id || createId(12),
1176
+ id: entryId,
906
1177
  type: "answer",
907
1178
  seatId: this.seatId,
908
1179
  origin: entry.origin || "unknown",
909
1180
  text: payload,
910
1181
  createdAt: entry.createdAt || new Date().toISOString(),
1182
+ chainId: pendingInboundContext?.chainId || entry.chainId || entryId,
1183
+ hop: pendingInboundContext ? pendingInboundContext.hop + 1 : 0,
911
1184
  challenge: this.trustState.challenge,
912
1185
  publicKey: this.identity.publicKey,
913
1186
  };
@@ -916,6 +1189,10 @@ class ArmedSeat {
916
1189
  this.identity.privateKey
917
1190
  );
918
1191
  appendJsonl(this.paths.eventsPath, signedEntry);
1192
+ this.rememberEmittedAnswer(answerKey);
1193
+ if (pendingInboundContext) {
1194
+ pendingInboundContext.relayUsed = true;
1195
+ }
919
1196
 
920
1197
  this.log(`[${this.seatId}] ${previewText(payload)}`);
921
1198
  }
@@ -1028,6 +1305,17 @@ function previewText(text, maxLength = 88) {
1028
1305
  return `${compact.slice(0, maxLength - 3)}...`;
1029
1306
  }
1030
1307
 
1308
+ function buildAnswerKey(entry, payload) {
1309
+ const origin = String(entry.origin || "unknown").trim() || "unknown";
1310
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
1311
+ if (id) {
1312
+ return `${origin}:${id}`;
1313
+ }
1314
+
1315
+ const createdAt = typeof entry.createdAt === "string" ? entry.createdAt : "";
1316
+ return `${origin}:${createdAt}:${hashText(payload)}`;
1317
+ }
1318
+
1031
1319
  function buildSeatReport(sessionName, seatId) {
1032
1320
  const paths = getSeatPaths(sessionName, seatId);
1033
1321
  const daemon = readJson(paths.daemonPath, null);
@@ -1136,6 +1424,7 @@ function stopAllSessions() {
1136
1424
 
1137
1425
  module.exports = {
1138
1426
  ArmedSeat,
1427
+ buildChildEnv,
1139
1428
  getStatusReport,
1140
1429
  resolveSessionName,
1141
1430
  stopAllSessions,