muuuuse 0.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.js CHANGED
@@ -1,10 +1,10 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
- const { spawn } = require("node:child_process");
3
+ const pty = require("node-pty");
4
4
 
5
5
  const {
6
- PRESETS,
7
- detectAgent,
6
+ detectAgentTypeFromCommand,
7
+ expandPresetCommand,
8
8
  readClaudeAnswers,
9
9
  readCodexAnswers,
10
10
  readGeminiAnswers,
@@ -12,14 +12,12 @@ const {
12
12
  selectCodexSessionFile,
13
13
  selectGeminiSessionFile,
14
14
  } = require("./agents");
15
- const { capturePaneText, getPaneChildProcesses, getPaneInfo, paneExists, sendTextAndEnter, setPaneTitle } = require("./tmux");
16
15
  const {
17
16
  BRAND,
18
- CONTROLLER_WAIT_MS,
19
17
  POLL_MS,
20
18
  appendJsonl,
21
19
  createId,
22
- getControllerPath,
20
+ getDefaultSessionName,
23
21
  getFileSize,
24
22
  getSeatPaths,
25
23
  hashText,
@@ -32,553 +30,439 @@ const {
32
30
  writeJson,
33
31
  } = require("./util");
34
32
 
35
- function killExistingSeatDaemon(sessionName, seatId) {
36
- const { daemonPath } = getSeatPaths(sessionName, seatId);
37
- const daemon = readJson(daemonPath, null);
38
- if (daemon?.pid && isPidAlive(daemon.pid)) {
39
- try {
40
- process.kill(daemon.pid, "SIGTERM");
41
- } catch (error) {
42
- // Ignore stale pid races.
43
- }
44
- }
45
- }
33
+ const GENERIC_IDLE_MS = 900;
34
+ const GENERIC_FALLBACK_DELAY_MS = 4000;
46
35
 
47
- function spawnSeatDaemon(sessionName, seatId, binPath) {
48
- const child = spawn(process.execPath, [binPath, "daemon", sessionName, String(seatId)], {
49
- detached: true,
50
- stdio: "ignore",
51
- env: process.env,
52
- });
53
- child.unref();
36
+ function resolveSessionName(sessionOverride, currentPath = process.cwd()) {
37
+ return sessionOverride || getDefaultSessionName(currentPath);
54
38
  }
55
39
 
56
- function armSeat({ seatId, paneInfo, binPath }) {
57
- killExistingSeatDaemon(paneInfo.sessionName, seatId);
58
- const seatPaths = getSeatPaths(paneInfo.sessionName, seatId);
59
- resetDir(seatPaths.dir);
60
-
61
- const meta = {
62
- seatId,
63
- sessionName: paneInfo.sessionName,
64
- paneId: paneInfo.paneId,
65
- windowIndex: paneInfo.windowIndex,
66
- windowName: paneInfo.windowName,
67
- cwd: paneInfo.currentPath,
68
- armedAt: new Date().toISOString(),
69
- instanceId: createId(12),
70
- };
71
-
72
- writeJson(seatPaths.metaPath, meta);
73
- setPaneTitle(paneInfo.paneId, `muuuuse ${seatId}`);
74
- spawnSeatDaemon(paneInfo.sessionName, seatId, binPath);
75
- return meta;
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
+ }
45
+ return resolved;
76
46
  }
77
47
 
78
- function listArmedSeats(sessionName) {
79
- return [1, 2]
80
- .map((seatId) => {
81
- const seatPaths = getSeatPaths(sessionName, seatId);
82
- const meta = readJson(seatPaths.metaPath, null);
83
- if (!meta || !paneExists(meta.paneId)) {
84
- return null;
48
+ function formatCommand(commandTokens) {
49
+ return commandTokens
50
+ .map((token) => {
51
+ if (/^[a-zA-Z0-9._/@:=+-]+$/.test(token)) {
52
+ return token;
85
53
  }
86
- return meta;
54
+ return JSON.stringify(token);
87
55
  })
88
- .filter((entry) => entry !== null);
56
+ .join(" ");
89
57
  }
90
58
 
91
- function findSeatByPane(sessionName, paneId) {
92
- return listArmedSeats(sessionName).find((seat) => seat.paneId === paneId) || null;
93
- }
94
-
95
- function configureScript({ sessionName, paneId, steps }) {
96
- const seat = findSeatByPane(sessionName, paneId);
97
- if (!seat) {
98
- throw new Error("This pane is not armed. Run `muuuuse 1` or `muuuuse 2` first.");
99
- }
100
-
101
- const normalizedSteps = steps
102
- .map((step) => sanitizeRelayText(step))
103
- .filter((step) => step.length > 0);
104
-
105
- if (normalizedSteps.length === 0) {
106
- throw new Error("Script mode needs at least one non-empty step.");
59
+ function previewText(text, maxLength = 88) {
60
+ const compact = sanitizeRelayText(text).replace(/\s+/g, " ");
61
+ if (compact.length <= maxLength) {
62
+ return compact;
107
63
  }
108
-
109
- const seatPaths = getSeatPaths(sessionName, seat.seatId);
110
- writeJson(seatPaths.scriptPath, {
111
- mode: "script",
112
- cursor: 0,
113
- steps: normalizedSteps,
114
- updatedAt: new Date().toISOString(),
115
- });
116
- setPaneTitle(paneId, `muuuuse ${seat.seatId} script`);
117
- return {
118
- seatId: seat.seatId,
119
- steps: normalizedSteps,
120
- };
64
+ return `${compact.slice(0, maxLength - 3)}...`;
121
65
  }
122
66
 
123
- function enableLiveMode({ sessionName, paneId }) {
124
- const seat = findSeatByPane(sessionName, paneId);
125
- if (!seat) {
126
- throw new Error("This pane is not armed. Run `muuuuse 1` or `muuuuse 2` first.");
127
- }
128
-
129
- const seatPaths = getSeatPaths(sessionName, seat.seatId);
130
- fs.rmSync(seatPaths.scriptPath, { force: true });
131
- setPaneTitle(paneId, `muuuuse ${seat.seatId}`);
132
- return seat;
67
+ function parseAnswerEntries(text) {
68
+ return String(text || "")
69
+ .split("\n")
70
+ .map((line) => line.trim())
71
+ .filter((line) => line.length > 0)
72
+ .map((line) => {
73
+ try {
74
+ return JSON.parse(line);
75
+ } catch (error) {
76
+ return null;
77
+ }
78
+ })
79
+ .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
133
80
  }
134
81
 
135
- function queueSeatCommand(sessionName, seatId, text, meta = {}) {
136
- const seatPaths = getSeatPaths(sessionName, seatId);
137
- const payload = sanitizeRelayText(text);
138
- if (!payload) {
139
- return null;
82
+ function resolveSessionFile(agentType, currentPath, processStartedAtMs) {
83
+ if (agentType === "codex") {
84
+ return selectCodexSessionFile(currentPath, processStartedAtMs);
140
85
  }
141
-
142
- const command = {
143
- id: createId(12),
144
- type: "deliver",
145
- text: payload,
146
- createdAt: new Date().toISOString(),
147
- ...meta,
148
- };
149
- appendJsonl(seatPaths.commandsPath, command);
150
- return command;
86
+ if (agentType === "claude") {
87
+ return selectClaudeSessionFile(currentPath, processStartedAtMs);
88
+ }
89
+ if (agentType === "gemini") {
90
+ return selectGeminiSessionFile(currentPath, processStartedAtMs);
91
+ }
92
+ return null;
151
93
  }
152
94
 
153
- class Controller {
154
- constructor(sessionName, options = {}) {
155
- this.sessionName = sessionName;
156
- this.seedSeat = options.seedSeat === 2 ? 2 : 1;
157
- this.seedText = sanitizeRelayText(options.seedText || "");
158
- this.maxRelays = Number.isFinite(options.maxRelays) ? options.maxRelays : Number.POSITIVE_INFINITY;
159
- this.relayCount = 0;
160
- this.stopped = false;
161
- this.offsets = { 1: 0, 2: 0 };
162
- this.controllerPath = getControllerPath(sessionName);
163
- this.seats = new Map();
95
+ class GenericAnswerTracker {
96
+ constructor() {
97
+ this.active = false;
98
+ this.buffer = "";
99
+ this.lastInputText = "";
100
+ this.lastOutputAt = 0;
101
+ this.lastFingerprint = null;
164
102
  }
165
103
 
166
- print(line = "") {
167
- process.stdout.write(`${line}\n`);
104
+ noteTurnStart(inputText = "") {
105
+ this.active = true;
106
+ this.buffer = "";
107
+ this.lastInputText = sanitizeRelayText(inputText);
108
+ this.lastOutputAt = 0;
168
109
  }
169
110
 
170
- installSignalHandlers() {
171
- const stop = () => {
172
- this.stopped = true;
173
- };
174
- process.once("SIGINT", stop);
175
- process.once("SIGTERM", stop);
176
- }
111
+ append(data) {
112
+ if (!this.active) {
113
+ return;
114
+ }
177
115
 
178
- async waitForSeats() {
179
- this.print(`${BRAND} controller is waiting for seats 1 and 2 in tmux session ${this.sessionName}.`);
116
+ this.buffer += String(data || "");
117
+ this.lastOutputAt = Date.now();
180
118
 
181
- while (!this.stopped) {
182
- const seats = listArmedSeats(this.sessionName);
183
- this.seats = new Map(seats.map((seat) => [seat.seatId, seat]));
184
- if (this.seats.has(1) && this.seats.has(2)) {
185
- return;
186
- }
187
- await sleep(CONTROLLER_WAIT_MS);
119
+ if (this.buffer.length > 24000) {
120
+ this.buffer = this.buffer.slice(-24000);
188
121
  }
189
122
  }
190
123
 
191
- initializeOffsets() {
192
- for (const seatId of [1, 2]) {
193
- const { eventsPath } = getSeatPaths(this.sessionName, seatId);
194
- this.offsets[seatId] = getFileSize(eventsPath);
124
+ consumeReady() {
125
+ if (!this.active || !this.lastOutputAt) {
126
+ return null;
195
127
  }
196
- }
197
128
 
198
- writeState() {
199
- writeJson(this.controllerPath, {
200
- pid: process.pid,
201
- sessionName: this.sessionName,
202
- seedSeat: this.seedSeat,
203
- relays: this.relayCount,
204
- startedAt: new Date().toISOString(),
205
- });
206
- }
129
+ if (Date.now() - this.lastOutputAt < GENERIC_IDLE_MS) {
130
+ return null;
131
+ }
207
132
 
208
- removeState() {
209
- const current = readJson(this.controllerPath, null);
210
- if (current?.pid === process.pid) {
211
- fs.rmSync(this.controllerPath, { force: true });
133
+ const text = extractGenericAnswer(this.buffer, this.lastInputText);
134
+ if (!text) {
135
+ return null;
212
136
  }
213
- }
214
137
 
215
- async run() {
216
- this.installSignalHandlers();
217
- await this.waitForSeats();
218
- if (this.stopped) {
219
- return 0;
138
+ const fingerprint = hashText(`${this.lastInputText}\n${text}`);
139
+ if (fingerprint === this.lastFingerprint) {
140
+ return null;
220
141
  }
221
142
 
222
- this.initializeOffsets();
223
- this.writeState();
143
+ this.lastFingerprint = fingerprint;
144
+ this.active = false;
145
+ return text;
146
+ }
147
+ }
224
148
 
225
- this.print(`${BRAND} linked seat 1 and seat 2 in session ${this.sessionName}.`);
226
- this.print("Final answers only. Remote routing belongs to Codeman.");
149
+ function extractGenericAnswer(rawText, lastInputText) {
150
+ let candidate = sanitizeRelayText(rawText, 12000);
151
+ if (!candidate) {
152
+ return null;
153
+ }
227
154
 
228
- if (this.seedText) {
229
- queueSeatCommand(this.sessionName, this.seedSeat, this.seedText, {
230
- source: "controller_seed",
231
- });
232
- this.print(`Kickoff -> seat ${this.seedSeat}: ${previewText(this.seedText)}`);
155
+ if (lastInputText) {
156
+ if (candidate === lastInputText) {
157
+ return null;
233
158
  }
234
-
235
- try {
236
- while (!this.stopped) {
237
- await this.forwardNewAnswers();
238
- if (this.relayCount >= this.maxRelays) {
239
- this.print(`${BRAND} hit the relay cap (${this.maxRelays}).`);
240
- return 0;
241
- }
242
- await sleep(POLL_MS);
243
- }
244
- return 0;
245
- } finally {
246
- this.removeState();
159
+ if (candidate.startsWith(`${lastInputText}\n`)) {
160
+ candidate = candidate.slice(lastInputText.length).trim();
247
161
  }
248
162
  }
249
163
 
250
- async forwardNewAnswers() {
251
- for (const seatId of [1, 2]) {
252
- const targetSeatId = seatId === 1 ? 2 : 1;
253
- const { eventsPath } = getSeatPaths(this.sessionName, seatId);
254
- const { nextOffset, text } = readAppendedText(eventsPath, this.offsets[seatId]);
255
- this.offsets[seatId] = nextOffset;
256
- if (!text.trim()) {
257
- continue;
258
- }
164
+ const blocks = candidate
165
+ .split(/\n{2,}/)
166
+ .map((block) => block.trim())
167
+ .filter((block) => block.length > 0);
259
168
 
260
- const entries = text
261
- .split("\n")
262
- .map((line) => line.trim())
263
- .filter((line) => line.length > 0)
264
- .map((line) => {
265
- try {
266
- return JSON.parse(line);
267
- } catch (error) {
268
- return null;
269
- }
270
- })
271
- .filter((entry) => entry && entry.type === "answer" && typeof entry.text === "string");
272
-
273
- for (const entry of entries) {
274
- const queued = queueSeatCommand(this.sessionName, targetSeatId, entry.text, {
275
- sourceSeat: seatId,
276
- sourceEventId: entry.id,
277
- });
278
- if (!queued) {
279
- continue;
280
- }
281
- this.relayCount += 1;
282
- this.print(`[${seatId} -> ${targetSeatId}] ${previewText(entry.text)}`);
283
- if (this.relayCount >= this.maxRelays) {
284
- return;
285
- }
286
- }
287
- }
169
+ if (blocks.length === 0) {
170
+ return null;
288
171
  }
172
+
173
+ return sanitizeRelayText(blocks[blocks.length - 1]);
289
174
  }
290
175
 
291
- class SeatDaemon {
292
- constructor(sessionName, seatId) {
293
- this.sessionName = sessionName;
294
- this.seatId = seatId;
295
- this.paths = getSeatPaths(sessionName, seatId);
296
- this.commandOffset = 0;
176
+ class SeatProcess {
177
+ constructor(options) {
178
+ this.seatId = options.seatId;
179
+ this.partnerSeatId = options.seatId === 1 ? 2 : 1;
180
+ this.sessionName = options.sessionName;
181
+ this.cwd = options.cwd;
182
+ this.commandTokens = [...options.commandTokens];
183
+ this.agentType = detectAgentTypeFromCommand(this.commandTokens);
184
+ this.maxRelays = options.maxRelays;
185
+
186
+ this.paths = getSeatPaths(this.sessionName, this.seatId);
187
+ this.partnerPaths = getSeatPaths(this.sessionName, this.partnerSeatId);
188
+ this.partnerOffset = getFileSize(this.partnerPaths.eventsPath);
189
+
190
+ this.child = null;
191
+ this.childPid = null;
192
+ this.childExit = null;
193
+ this.startedAtMs = Date.now();
194
+ this.relayCount = 0;
195
+ this.linked = false;
297
196
  this.stopped = false;
298
- this.liveState = {
299
- type: null,
300
- pid: null,
301
- currentPath: null,
302
- sessionFile: null,
197
+ this.stdinCleanup = null;
198
+ this.resizeCleanup = null;
199
+
200
+ this.sessionState = {
201
+ file: null,
303
202
  offset: 0,
304
203
  lastMessageId: null,
305
- processStartedAtMs: null,
306
- };
307
- this.paneState = {
308
- text: "",
309
- changedAt: 0,
310
- lastCandidateHash: null,
311
204
  };
205
+
206
+ this.genericTracker = new GenericAnswerTracker();
312
207
  }
313
208
 
314
- installSignalHandlers() {
315
- const stop = () => {
316
- this.stopped = true;
317
- };
318
- process.once("SIGINT", stop);
319
- process.once("SIGTERM", stop);
209
+ log(message) {
210
+ process.stderr.write(`${message}\n`);
320
211
  }
321
212
 
322
- writeDaemonState() {
323
- writeJson(this.paths.daemonPath, {
213
+ writeMeta(extra = {}) {
214
+ writeJson(this.paths.metaPath, {
215
+ seatId: this.seatId,
216
+ sessionName: this.sessionName,
217
+ cwd: this.cwd,
324
218
  pid: process.pid,
219
+ childPid: this.childPid,
220
+ agentType: this.agentType,
221
+ command: this.commandTokens,
222
+ commandLine: formatCommand(this.commandTokens),
223
+ startedAt: new Date(this.startedAtMs).toISOString(),
224
+ ...extra,
225
+ });
226
+ }
227
+
228
+ writeStatus(extra = {}) {
229
+ writeJson(this.paths.statusPath, {
325
230
  seatId: this.seatId,
326
231
  sessionName: this.sessionName,
327
- startedAt: new Date().toISOString(),
232
+ cwd: this.cwd,
233
+ pid: process.pid,
234
+ childPid: this.childPid,
235
+ agentType: this.agentType,
236
+ command: this.commandTokens,
237
+ relayCount: this.relayCount,
238
+ updatedAt: new Date().toISOString(),
239
+ ...extra,
328
240
  });
329
241
  }
330
242
 
331
- removeDaemonState() {
332
- const current = readJson(this.paths.daemonPath, null);
333
- if (current?.pid === process.pid) {
334
- fs.rmSync(this.paths.daemonPath, { force: true });
335
- }
243
+ installSignalHandlers() {
244
+ const stop = () => {
245
+ this.stopped = true;
246
+ };
247
+ process.once("SIGINT", stop);
248
+ process.once("SIGTERM", stop);
336
249
  }
337
250
 
338
- async run() {
339
- this.installSignalHandlers();
340
- this.writeDaemonState();
251
+ installStdinProxy() {
252
+ const handleData = (chunk) => {
253
+ if (!this.child) {
254
+ return;
255
+ }
341
256
 
342
- try {
343
- while (!this.stopped) {
344
- await this.tick();
345
- await sleep(POLL_MS);
257
+ const text = chunk.toString("utf8");
258
+ this.child.write(text);
259
+ if (this.shouldUseGenericCapture() && /[\r\n]/.test(text)) {
260
+ this.genericTracker.noteTurnStart("");
346
261
  }
347
- return 0;
348
- } finally {
349
- this.removeDaemonState();
262
+ };
263
+
264
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
265
+ process.stdin.setRawMode(true);
350
266
  }
267
+ process.stdin.resume();
268
+ process.stdin.on("data", handleData);
269
+
270
+ this.stdinCleanup = () => {
271
+ process.stdin.off("data", handleData);
272
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
273
+ process.stdin.setRawMode(false);
274
+ }
275
+ };
351
276
  }
352
277
 
353
- async tick() {
354
- const meta = readJson(this.paths.metaPath, null);
355
- if (!meta || !paneExists(meta.paneId)) {
356
- this.writeStatus({ state: "waiting_for_pane" });
278
+ installResizeHandler() {
279
+ if (!process.stdout.isTTY) {
357
280
  return;
358
281
  }
359
282
 
360
- const paneInfo = getPaneInfo(meta.paneId);
361
- if (!paneInfo) {
362
- this.writeStatus({ state: "waiting_for_pane" });
283
+ const handleResize = () => {
284
+ if (!this.child) {
285
+ return;
286
+ }
287
+
288
+ try {
289
+ this.child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
290
+ } catch (error) {
291
+ // Ignore resize failures while the child is exiting.
292
+ }
293
+ };
294
+
295
+ process.stdout.on("resize", handleResize);
296
+ this.resizeCleanup = () => {
297
+ process.stdout.off("resize", handleResize);
298
+ };
299
+ }
300
+
301
+ launchChild() {
302
+ resetDir(this.paths.dir);
303
+
304
+ const [file, ...args] = this.commandTokens;
305
+ this.child = pty.spawn(file, args, {
306
+ cols: process.stdout.columns || 80,
307
+ cwd: this.cwd,
308
+ env: {
309
+ ...process.env,
310
+ MUUUUSE_SEAT: String(this.seatId),
311
+ MUUUUSE_SESSION: this.sessionName,
312
+ },
313
+ name: process.env.TERM || "xterm-256color",
314
+ rows: process.stdout.rows || 24,
315
+ });
316
+
317
+ this.childPid = this.child.pid;
318
+ this.writeMeta();
319
+ this.writeStatus({
320
+ partnerSeatId: this.partnerSeatId,
321
+ state: "running",
322
+ });
323
+
324
+ this.child.onData((data) => {
325
+ process.stdout.write(data);
326
+ if (this.shouldUseGenericCapture()) {
327
+ this.genericTracker.append(data);
328
+ }
329
+ });
330
+
331
+ this.child.onExit(({ exitCode, signal }) => {
332
+ this.childExit = {
333
+ exitCode,
334
+ signal: signal || null,
335
+ };
336
+ this.stopped = true;
337
+ });
338
+ }
339
+
340
+ partnerIsLive() {
341
+ const partnerStatus = readJson(this.partnerPaths.statusPath, null);
342
+ return Boolean(partnerStatus?.pid && isPidAlive(partnerStatus.pid));
343
+ }
344
+
345
+ maybeMarkLinked() {
346
+ if (this.linked || !this.partnerIsLive()) {
363
347
  return;
364
348
  }
365
349
 
366
- if (paneInfo.currentPath !== meta.cwd || paneInfo.windowName !== meta.windowName) {
367
- writeJson(this.paths.metaPath, {
368
- ...meta,
369
- cwd: paneInfo.currentPath,
370
- windowName: paneInfo.windowName,
371
- paneId: paneInfo.paneId,
372
- });
373
- }
350
+ this.linked = true;
351
+ this.log(`${BRAND} seat ${this.seatId} linked with seat ${this.partnerSeatId} in session ${this.sessionName}.`);
352
+ }
374
353
 
375
- const script = readJson(this.paths.scriptPath, null);
376
- this.processCommands(meta, script);
354
+ shouldUseGenericCapture() {
355
+ if (!this.agentType) {
356
+ return true;
357
+ }
377
358
 
378
- if (script && Array.isArray(script.steps) && script.steps.length > 0) {
379
- this.writeStatus({
380
- state: "script",
381
- scriptSteps: script.steps.length,
382
- cursor: script.cursor || 0,
383
- cwd: paneInfo.currentPath,
384
- });
385
- return;
359
+ if (this.sessionState.file) {
360
+ return false;
386
361
  }
387
362
 
388
- this.collectLiveAnswers(meta, paneInfo);
363
+ return Date.now() - this.startedAtMs >= GENERIC_FALLBACK_DELAY_MS;
389
364
  }
390
365
 
391
- processCommands(meta, script) {
392
- const { nextOffset, text } = readAppendedText(this.paths.commandsPath, this.commandOffset);
393
- this.commandOffset = nextOffset;
366
+ pullPartnerEvents() {
367
+ const { nextOffset, text } = readAppendedText(this.partnerPaths.eventsPath, this.partnerOffset);
368
+ this.partnerOffset = nextOffset;
394
369
  if (!text.trim()) {
395
370
  return;
396
371
  }
397
372
 
398
- const commands = text
399
- .split("\n")
400
- .map((line) => line.trim())
401
- .filter((line) => line.length > 0)
402
- .map((line) => {
403
- try {
404
- return JSON.parse(line);
405
- } catch (error) {
406
- return null;
407
- }
408
- })
409
- .filter((entry) => entry && entry.type === "deliver" && typeof entry.text === "string");
410
-
411
- for (const command of commands) {
412
- if (script && Array.isArray(script.steps) && script.steps.length > 0) {
413
- this.handleScriptTurn(meta, script, command);
373
+ const entries = parseAnswerEntries(text);
374
+ for (const entry of entries) {
375
+ if (!this.child) {
376
+ continue;
377
+ }
378
+ if (Number.isFinite(this.maxRelays) && this.relayCount >= this.maxRelays) {
379
+ this.log(`${BRAND} seat ${this.seatId} hit the relay cap (${this.maxRelays}).`);
414
380
  continue;
415
381
  }
416
382
 
417
- sendTextAndEnter(meta.paneId, command.text);
418
- }
419
- }
420
-
421
- handleScriptTurn(meta, script, command) {
422
- const steps = Array.isArray(script.steps) ? script.steps.filter((step) => step.length > 0) : [];
423
- if (steps.length === 0) {
424
- return;
425
- }
383
+ const payload = sanitizeRelayText(entry.text);
384
+ if (!payload) {
385
+ continue;
386
+ }
426
387
 
427
- const cursor = Number.isInteger(script.cursor) ? script.cursor : 0;
428
- const nextText = steps[cursor % steps.length];
429
- sendTextAndEnter(meta.paneId, nextText);
388
+ if (this.shouldUseGenericCapture()) {
389
+ this.genericTracker.noteTurnStart(payload);
390
+ }
430
391
 
431
- const nextScript = {
432
- ...script,
433
- cursor: (cursor + 1) % steps.length,
434
- updatedAt: new Date().toISOString(),
435
- };
436
- writeJson(this.paths.scriptPath, nextScript);
437
- this.emitAnswer({
438
- id: createId(12),
439
- origin: "script",
440
- text: nextText,
441
- createdAt: new Date().toISOString(),
442
- });
392
+ this.child.write(payload.replace(/\n/g, "\r"));
393
+ this.child.write("\r");
394
+ this.relayCount += 1;
395
+ this.log(`[${this.partnerSeatId} -> ${this.seatId}] ${previewText(payload)}`);
396
+ }
443
397
  }
444
398
 
445
- collectLiveAnswers(meta, paneInfo) {
446
- const detectedAgent = detectAgent(getPaneChildProcesses(meta.paneId));
447
- if (!detectedAgent) {
448
- this.liveState = {
449
- type: null,
450
- pid: null,
451
- currentPath: paneInfo.currentPath,
452
- sessionFile: null,
453
- offset: 0,
454
- lastMessageId: null,
455
- processStartedAtMs: null,
456
- };
457
- this.writeStatus({
458
- state: "armed",
459
- cwd: paneInfo.currentPath,
460
- agent: null,
461
- });
399
+ resolveStructuredLog() {
400
+ if (!this.agentType || this.sessionState.file) {
462
401
  return;
463
402
  }
464
403
 
465
- const changed =
466
- this.liveState.type !== detectedAgent.type ||
467
- this.liveState.pid !== detectedAgent.pid ||
468
- this.liveState.currentPath !== paneInfo.currentPath;
469
-
470
- if (changed) {
471
- this.liveState = {
472
- type: detectedAgent.type,
473
- pid: detectedAgent.pid,
474
- currentPath: paneInfo.currentPath,
475
- sessionFile: null,
476
- offset: 0,
477
- lastMessageId: null,
478
- processStartedAtMs: detectedAgent.processStartedAtMs,
479
- };
404
+ const sessionFile = resolveSessionFile(this.agentType, this.cwd, this.startedAtMs);
405
+ if (!sessionFile) {
406
+ return;
480
407
  }
481
408
 
482
- if (!this.liveState.sessionFile) {
483
- this.liveState.sessionFile = resolveSessionFile(
484
- detectedAgent.type,
485
- paneInfo.currentPath,
486
- detectedAgent.processStartedAtMs,
487
- meta.paneId
488
- );
489
- if (this.liveState.sessionFile) {
490
- if (detectedAgent.type === "gemini") {
491
- const baseline = readGeminiAnswers(this.liveState.sessionFile, null);
492
- this.liveState.lastMessageId = baseline.lastMessageId;
493
- this.liveState.offset = baseline.fileSize;
494
- } else {
495
- this.liveState.offset = getFileSize(this.liveState.sessionFile);
496
- }
497
- }
409
+ this.sessionState.file = sessionFile;
410
+ if (this.agentType === "gemini") {
411
+ const baseline = readGeminiAnswers(sessionFile, null);
412
+ this.sessionState.lastMessageId = baseline.lastMessageId;
413
+ this.sessionState.offset = baseline.fileSize;
414
+ } else {
415
+ this.sessionState.offset = getFileSize(sessionFile);
498
416
  }
417
+ }
499
418
 
500
- if (!this.liveState.sessionFile) {
501
- this.writeStatus({
502
- state: "armed",
503
- cwd: paneInfo.currentPath,
504
- agent: detectedAgent.type,
505
- log: "waiting_for_session_log",
506
- });
419
+ collectStructuredAnswers() {
420
+ this.resolveStructuredLog();
421
+ if (!this.sessionState.file || !this.agentType) {
507
422
  return;
508
423
  }
509
424
 
510
425
  const answers = [];
511
- if (detectedAgent.type === "codex") {
512
- const result = readCodexAnswers(this.liveState.sessionFile, this.liveState.offset);
513
- this.liveState.offset = result.nextOffset;
426
+ if (this.agentType === "codex") {
427
+ const result = readCodexAnswers(this.sessionState.file, this.sessionState.offset);
428
+ this.sessionState.offset = result.nextOffset;
514
429
  answers.push(...result.answers);
515
- } else if (detectedAgent.type === "claude") {
516
- const result = readClaudeAnswers(this.liveState.sessionFile, this.liveState.offset);
517
- this.liveState.offset = result.nextOffset;
430
+ } else if (this.agentType === "claude") {
431
+ const result = readClaudeAnswers(this.sessionState.file, this.sessionState.offset);
432
+ this.sessionState.offset = result.nextOffset;
518
433
  answers.push(...result.answers);
519
- } else if (detectedAgent.type === "gemini") {
520
- const result = readGeminiAnswers(this.liveState.sessionFile, this.liveState.lastMessageId);
521
- this.liveState.lastMessageId = result.lastMessageId;
522
- this.liveState.offset = result.fileSize;
434
+ } else if (this.agentType === "gemini") {
435
+ const result = readGeminiAnswers(this.sessionState.file, this.sessionState.lastMessageId);
436
+ this.sessionState.lastMessageId = result.lastMessageId;
437
+ this.sessionState.offset = result.fileSize;
523
438
  answers.push(...result.answers);
524
439
  }
525
440
 
526
441
  for (const answer of answers) {
527
442
  this.emitAnswer({
528
- id: answer.id || createId(12),
529
- origin: detectedAgent.type,
443
+ createdAt: answer.timestamp,
444
+ id: answer.id,
445
+ origin: this.agentType,
530
446
  text: answer.text,
531
- createdAt: answer.timestamp || new Date().toISOString(),
532
447
  });
533
448
  }
534
-
535
- this.collectPaneFallback(meta, detectedAgent);
536
-
537
- this.writeStatus({
538
- state: "armed",
539
- cwd: paneInfo.currentPath,
540
- agent: detectedAgent.type,
541
- log: this.liveState.sessionFile,
542
- lastAnswerAt: answers.length > 0 ? answers[answers.length - 1].timestamp : undefined,
543
- });
544
449
  }
545
450
 
546
- collectPaneFallback(meta, detectedAgent) {
547
- if (detectedAgent.type !== "codex") {
548
- return;
549
- }
550
-
551
- const paneText = capturePaneText(meta.paneId, 240);
552
- if (!paneText.trim()) {
553
- return;
554
- }
555
-
556
- if (paneText !== this.paneState.text) {
557
- this.paneState.text = paneText;
558
- this.paneState.changedAt = Date.now();
451
+ collectGenericAnswers() {
452
+ if (!this.shouldUseGenericCapture()) {
559
453
  return;
560
454
  }
561
455
 
562
- if (Date.now() - this.paneState.changedAt < 2200) {
563
- return;
564
- }
565
-
566
- const candidate = extractCodexPaneAnswer(paneText);
567
- if (!candidate) {
568
- return;
569
- }
570
-
571
- const candidateHash = hashText(`codex-pane:${candidate}:${paneText}`);
572
- if (candidateHash === this.paneState.lastCandidateHash) {
456
+ const text = this.genericTracker.consumeReady();
457
+ if (!text) {
573
458
  return;
574
459
  }
575
460
 
576
- this.paneState.lastCandidateHash = candidateHash;
577
461
  this.emitAnswer({
578
- id: createId(12),
579
- origin: "codex_pane",
580
- text: candidate,
581
462
  createdAt: new Date().toISOString(),
463
+ id: createId(12),
464
+ origin: "generic",
465
+ text,
582
466
  });
583
467
  }
584
468
 
@@ -596,88 +480,143 @@ class SeatDaemon {
596
480
  text,
597
481
  createdAt: entry.createdAt || new Date().toISOString(),
598
482
  });
483
+
484
+ this.log(`[${this.seatId}] ${previewText(text)}`);
599
485
  }
600
486
 
601
- writeStatus(extra) {
602
- writeJson(this.paths.statusPath, {
603
- seatId: this.seatId,
604
- sessionName: this.sessionName,
605
- pid: process.pid,
606
- updatedAt: new Date().toISOString(),
607
- ...extra,
487
+ async tick() {
488
+ this.maybeMarkLinked();
489
+ this.pullPartnerEvents();
490
+ this.collectStructuredAnswers();
491
+ this.collectGenericAnswers();
492
+
493
+ this.writeStatus({
494
+ partnerSeatId: this.partnerSeatId,
495
+ partnerLive: this.partnerIsLive(),
496
+ state: this.childExit ? "exited" : "running",
497
+ structuredLog: this.sessionState.file,
608
498
  });
609
499
  }
610
- }
611
500
 
612
- function resolveSessionFile(agentType, currentPath, processStartedAtMs, paneId = null) {
613
- if (agentType === "codex") {
614
- return selectCodexSessionFile(currentPath, processStartedAtMs, paneId);
615
- }
616
- if (agentType === "claude") {
617
- return selectClaudeSessionFile(currentPath, processStartedAtMs);
618
- }
619
- if (agentType === "gemini") {
620
- return selectGeminiSessionFile(currentPath, processStartedAtMs);
621
- }
622
- return null;
623
- }
501
+ async run() {
502
+ this.installSignalHandlers();
503
+ this.launchChild();
504
+ this.installStdinProxy();
505
+ this.installResizeHandler();
624
506
 
625
- function previewText(text, maxLength = 88) {
626
- const compact = sanitizeRelayText(text).replace(/\s+/g, " ");
627
- if (compact.length <= maxLength) {
628
- return compact;
507
+ this.log(`${BRAND} seat ${this.seatId} started in session ${this.sessionName}.`);
508
+ this.log(`Command: ${formatCommand(this.commandTokens)}`);
509
+ this.log(`Stop both seats from another terminal with: muuuuse 3 stop`);
510
+
511
+ try {
512
+ while (!this.stopped) {
513
+ await this.tick();
514
+ await sleep(POLL_MS);
515
+ }
516
+ } finally {
517
+ this.cleanup();
518
+ }
519
+
520
+ return this.childExit?.exitCode ?? 0;
629
521
  }
630
- return `${compact.slice(0, maxLength - 3)}...`;
631
- }
632
522
 
633
- function extractCodexPaneAnswer(paneText) {
634
- const lines = String(paneText || "").replace(/\r/g, "").split("\n");
635
- let promptIndex = -1;
523
+ cleanup() {
524
+ if (this.stdinCleanup) {
525
+ this.stdinCleanup();
526
+ this.stdinCleanup = null;
527
+ }
528
+ if (this.resizeCleanup) {
529
+ this.resizeCleanup();
530
+ this.resizeCleanup = null;
531
+ }
636
532
 
637
- for (let index = lines.length - 1; index >= 0; index -= 1) {
638
- if (/^\s*›\s+/.test(lines[index])) {
639
- promptIndex = index;
640
- break;
533
+ if (this.child && !this.childExit) {
534
+ try {
535
+ this.child.kill("SIGTERM");
536
+ } catch (error) {
537
+ // Ignore races during shutdown.
538
+ }
641
539
  }
540
+
541
+ this.writeMeta({
542
+ childPid: this.childPid,
543
+ exitedAt: new Date().toISOString(),
544
+ });
545
+ this.writeStatus({
546
+ childPid: this.childPid,
547
+ exitCode: this.childExit?.exitCode ?? null,
548
+ exitedAt: new Date().toISOString(),
549
+ partnerSeatId: this.partnerSeatId,
550
+ state: "exited",
551
+ });
642
552
  }
553
+ }
643
554
 
644
- const searchEnd = promptIndex === -1 ? lines.length - 1 : promptIndex - 1;
645
- let answerStart = -1;
555
+ function stopSession(sessionName) {
556
+ const results = [];
557
+
558
+ for (const seatId of [1, 2]) {
559
+ const paths = getSeatPaths(sessionName, seatId);
560
+ const status = readJson(paths.statusPath, null);
561
+ const meta = readJson(paths.metaPath, null);
562
+ const wrapperPid = status?.pid || meta?.pid || null;
563
+ const childPid = status?.childPid || meta?.childPid || null;
564
+
565
+ let wrapperStopped = false;
566
+ let childStopped = false;
567
+
568
+ if (wrapperPid && isPidAlive(wrapperPid)) {
569
+ try {
570
+ process.kill(wrapperPid, "SIGTERM");
571
+ wrapperStopped = true;
572
+ } catch (error) {
573
+ wrapperStopped = false;
574
+ }
575
+ }
646
576
 
647
- for (let index = searchEnd; index >= 0; index -= 1) {
648
- if (/^\s*•\s+/.test(lines[index])) {
649
- answerStart = index;
650
- break;
577
+ if (childPid && isPidAlive(childPid)) {
578
+ try {
579
+ process.kill(childPid, "SIGTERM");
580
+ childStopped = true;
581
+ } catch (error) {
582
+ childStopped = false;
583
+ }
651
584
  }
652
- }
653
585
 
654
- if (answerStart === -1) {
655
- return "";
586
+ results.push({
587
+ seatId,
588
+ childPid,
589
+ childStopped,
590
+ wrapperPid,
591
+ wrapperStopped,
592
+ });
656
593
  }
657
594
 
658
- const answerLines = lines.slice(answerStart, searchEnd + 1);
659
- while (answerLines.length > 0 && answerLines[answerLines.length - 1].trim().length === 0) {
660
- answerLines.pop();
661
- }
662
- if (answerLines.length === 0) {
663
- return "";
664
- }
595
+ return {
596
+ sessionName,
597
+ seats: results,
598
+ };
599
+ }
665
600
 
666
- answerLines[0] = answerLines[0].replace(/^\s*•\s+/, "");
667
- return sanitizeRelayText(answerLines.join("\n"));
601
+ function readSessionStatus(sessionName) {
602
+ return {
603
+ sessionName,
604
+ seats: [1, 2].map((seatId) => {
605
+ const paths = getSeatPaths(sessionName, seatId);
606
+ const status = readJson(paths.statusPath, null);
607
+ return {
608
+ seatId,
609
+ status,
610
+ };
611
+ }),
612
+ };
668
613
  }
669
614
 
670
615
  module.exports = {
671
- BRAND,
672
- Controller,
673
- PRESETS,
674
- SeatDaemon,
675
- armSeat,
676
- configureScript,
677
- enableLiveMode,
678
- extractCodexPaneAnswer,
679
- findSeatByPane,
680
- listArmedSeats,
681
- previewText,
682
- queueSeatCommand,
616
+ SeatProcess,
617
+ formatCommand,
618
+ readSessionStatus,
619
+ resolveProgramTokens,
620
+ resolveSessionName,
621
+ stopSession,
683
622
  };