moshcode 0.74.0 → 0.75.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/package.json +1 -1
- package/src/billing.mjs +7 -1
- package/src/commands.mjs +10 -1
- package/src/engines.mjs +17 -36
- package/src/mirror.mjs +22 -0
- package/src/payments.mjs +5 -1
- package/src/pty.mjs +58 -1
- package/src/tui.mjs +31 -8
package/package.json
CHANGED
package/src/billing.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { spawnSync } from "node:child_process";
|
|
|
19
19
|
|
|
20
20
|
import { loadBusiness, loadTimers, newId, updateBusiness, updateTimers } from "./business-store.mjs";
|
|
21
21
|
import { clientLabel, parseFields, resolveClient } from "./clients.mjs";
|
|
22
|
+
import { captureSpec } from "./pty.mjs";
|
|
22
23
|
import { GATEWAYS, defaultGateway, gatewayState } from "./payments.mjs";
|
|
23
24
|
import { chargeFor, describeRate, formatMoney, isDollarPegged, isFiat, rateFor } from "./rates.mjs";
|
|
24
25
|
import { humanDuration, selectEntries, windowFrom } from "./timer.mjs";
|
|
@@ -289,7 +290,12 @@ function handOff(record, invoice, business, fields, write, run) {
|
|
|
289
290
|
return 0;
|
|
290
291
|
}
|
|
291
292
|
|
|
292
|
-
|
|
293
|
+
// Mirrored like every other hand-off: sending an invoice is exactly the kind
|
|
294
|
+
// of thing you want to read back from the session page afterwards.
|
|
295
|
+
const launch = captureSpec({ cmd: "coinpay", args });
|
|
296
|
+
let result;
|
|
297
|
+
try { result = run(launch.cmd, launch.args, { stdio: "inherit" }); }
|
|
298
|
+
finally { launch.stop(); }
|
|
293
299
|
if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
|
|
294
300
|
if (result?.status) { write(err(`coinpay exited ${result.status} — invoice ${record.id} is still a local draft`)); return result.status; }
|
|
295
301
|
updateBusiness((data) => {
|
package/src/commands.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
|
|
|
22
22
|
import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
|
|
23
23
|
import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
|
|
24
24
|
import { shellInvocation } from "./shell.mjs";
|
|
25
|
+
import { captureSpec } from "./pty.mjs";
|
|
25
26
|
import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
|
|
26
27
|
import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
27
28
|
import { CORE_CLI_COMMAND_NAMES, PIT_COMMANDS } from "./cli-schema.mjs";
|
|
@@ -116,7 +117,15 @@ const SHELL = {
|
|
|
116
117
|
// has the reasoning, including why a headless run stays non-interactive.
|
|
117
118
|
const { shell: sh, args: shArgs } = shellInvocation(cmd);
|
|
118
119
|
ctx.out(` ▶ shell: ${cmd}`);
|
|
119
|
-
|
|
120
|
+
// Captured for the session mirror like the pit's own `!cmd`. A blocking
|
|
121
|
+
// spawn holds the event loop, so the follower's poll never runs and the
|
|
122
|
+
// whole command arrives in the drain stop() does — batched rather than
|
|
123
|
+
// live, which is still the difference between reading it from a phone and
|
|
124
|
+
// not.
|
|
125
|
+
const launch = captureSpec({ cmd: sh, args: shArgs });
|
|
126
|
+
let res;
|
|
127
|
+
try { res = spawnSync(launch.cmd, launch.args, { stdio: "inherit" }); }
|
|
128
|
+
finally { launch.stop(); }
|
|
120
129
|
if (res.error) throw res.error;
|
|
121
130
|
const code = res.status ?? 1;
|
|
122
131
|
if (code !== 0) {
|
package/src/engines.mjs
CHANGED
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
// a session that starts fresh is a small disappointment, and one that starts
|
|
33
33
|
// with a flag the engine does not have is a crash.
|
|
34
34
|
import { spawn } from "node:child_process";
|
|
35
|
-
import { existsSync,
|
|
36
|
-
import { homedir
|
|
35
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
37
|
import path from "node:path";
|
|
38
38
|
|
|
39
|
-
import {
|
|
39
|
+
import { captureSpec } from "./pty.mjs";
|
|
40
40
|
|
|
41
41
|
export const ENGINES = {
|
|
42
42
|
opencode: {
|
|
@@ -439,21 +439,29 @@ export function runCmd(cmd, args = [], { capture = false } = {}) {
|
|
|
439
439
|
let child;
|
|
440
440
|
const spec = spawnSpec(cmd, args);
|
|
441
441
|
const stdio = capture ? ["inherit", "pipe", "pipe"] : "inherit";
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
// The `capture` branch already reaches a watching browser: it re-writes
|
|
443
|
+
// every byte through this process's own stdout/stderr, which the mirror
|
|
444
|
+
// tees. The inherited branch does not — those bytes go to the tty and
|
|
445
|
+
// nowhere else — so it goes under a pty when a mirror is live. This is what
|
|
446
|
+
// an upgrade, a plugin install and an `mcp add` all run through, and all
|
|
447
|
+
// three used to be a rule, a blank stretch, and a result line.
|
|
448
|
+
const launch = capture ? { ...spec, stop: () => {} } : captureSpec(spec);
|
|
449
|
+
const finish = (result) => { try { launch.stop(); } catch { /* already drained */ } resolve(result); };
|
|
450
|
+
try { child = spawn(launch.cmd, launch.args, { stdio }); }
|
|
451
|
+
catch (e) { finish({ ok: false, error: e }); return; }
|
|
444
452
|
let output = "";
|
|
445
453
|
if (capture) {
|
|
446
454
|
for (const [stream, sink] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
|
|
447
455
|
stream?.on("data", (chunk) => { output += chunk.toString(); sink.write(chunk); });
|
|
448
456
|
}
|
|
449
457
|
}
|
|
450
|
-
child.on("error", (e) =>
|
|
458
|
+
child.on("error", (e) => finish({ ok: false, error: e, output }));
|
|
451
459
|
// "exit" fires as soon as the process is gone, which with pipes can leave
|
|
452
460
|
// the last chunk still queued — the one line we are trying to read. "close"
|
|
453
461
|
// waits for the streams too. With stdio inherited there are no streams, so
|
|
454
462
|
// the two are the same moment and existing callers are unaffected; the
|
|
455
463
|
// distinction is kept explicit so neither branch changes by accident.
|
|
456
|
-
child.on(capture ? "close" : "exit", (code, signal) =>
|
|
464
|
+
child.on(capture ? "close" : "exit", (code, signal) => finish({ ok: true, code, signal, output }));
|
|
457
465
|
});
|
|
458
466
|
}
|
|
459
467
|
|
|
@@ -501,35 +509,8 @@ export function openPassthrough(target, args = [], { onOutput } = {}) {
|
|
|
501
509
|
// the child the tty's own file descriptors, so none of its bytes ever pass
|
|
502
510
|
// through this process. See src/pty.mjs for why this is script(1) and not
|
|
503
511
|
// a pipe or node-pty.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
let stopFollow = null;
|
|
507
|
-
let launch = { ...spec, stdio: "inherit" };
|
|
508
|
-
if (ptyEnabled(onOutput)) {
|
|
509
|
-
try {
|
|
510
|
-
workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
|
|
511
|
-
transcript = path.join(workDir, "transcript");
|
|
512
|
-
writeFileSync(transcript, "");
|
|
513
|
-
const wrapped = ptySpec(spec.cmd, spec.args, transcript, scriptFlavor());
|
|
514
|
-
if (wrapped) {
|
|
515
|
-
launch = { ...wrapped, stdio: "inherit" };
|
|
516
|
-
let first = true;
|
|
517
|
-
stopFollow = followFile(transcript, (chunk) => {
|
|
518
|
-
const clean = stripScriptBanner(chunk, first);
|
|
519
|
-
first = false;
|
|
520
|
-
if (clean) onOutput(clean);
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
} catch {
|
|
524
|
-
// Capture is a nicety; never let it stop the session from opening.
|
|
525
|
-
transcript = null;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const cleanup = () => {
|
|
530
|
-
try { stopFollow?.(); } catch { /* nothing left to drain */ }
|
|
531
|
-
if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
|
|
532
|
-
};
|
|
512
|
+
const launch = captureSpec(spec, onOutput);
|
|
513
|
+
const cleanup = () => launch.stop();
|
|
533
514
|
|
|
534
515
|
let child;
|
|
535
516
|
try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); }
|
package/src/mirror.mjs
CHANGED
|
@@ -56,6 +56,28 @@ export function pressKey(name, rl = null, stdin = process.stdin) {
|
|
|
56
56
|
const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
|
|
57
57
|
const MAX_BUFFER = 16000; // flush early once a batch gets big
|
|
58
58
|
|
|
59
|
+
// Where a child process's output should be copied while a mirror is watching.
|
|
60
|
+
//
|
|
61
|
+
// Module-level rather than threaded through every call, because "is anyone
|
|
62
|
+
// watching this pit" is one fact about the process and the launchers that need
|
|
63
|
+
// it are scattered: the shell, the installers, the upgrader, the plugin/skill/
|
|
64
|
+
// MCP hand-offs. Passing it down by hand is what left most of them writing
|
|
65
|
+
// straight to the tty with the session page showing nothing — each new launcher
|
|
66
|
+
// had to remember, and none of them did. src/pty.mjs reads this as its default,
|
|
67
|
+
// so capture is what a launcher gets for free and opting out is the deliberate
|
|
68
|
+
// act.
|
|
69
|
+
let activeSink = null;
|
|
70
|
+
|
|
71
|
+
/** Point child capture at this mirror (or null when the pit stops mirroring). */
|
|
72
|
+
export function setActiveSink(sink) {
|
|
73
|
+
activeSink = typeof sink === "function" ? sink : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The sink a child's output should be copied to, or null when unmirrored. */
|
|
77
|
+
export function activeChildSink() {
|
|
78
|
+
return activeSink;
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
export function createMirror({
|
|
60
82
|
version = "",
|
|
61
83
|
cwd = process.cwd(),
|
package/src/payments.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// `/payments connect stripe` records a *reference* — vault and key name — and
|
|
23
23
|
// says out loud where the secret should go.
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { captureSpec } from "./pty.mjs";
|
|
25
26
|
|
|
26
27
|
import { loadBusiness, updateBusiness } from "./business-store.mjs";
|
|
27
28
|
import { parseFields } from "./clients.mjs";
|
|
@@ -178,7 +179,10 @@ function connectGateway(args, write, run) {
|
|
|
178
179
|
return 1;
|
|
179
180
|
}
|
|
180
181
|
write(info(`handing you to ${bone(gateway.bin)} — it owns its own session`));
|
|
181
|
-
const
|
|
182
|
+
const launch = captureSpec({ cmd: gateway.bin, args: gateway.connect });
|
|
183
|
+
let result;
|
|
184
|
+
try { result = run(launch.cmd, launch.args, { stdio: "inherit" }); }
|
|
185
|
+
finally { launch.stop(); }
|
|
182
186
|
if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
|
|
183
187
|
if (result?.status) {
|
|
184
188
|
write(err(`${gateway.bin} ${gateway.connect.join(" ")} exited ${result.status} — nothing recorded`));
|
package/src/pty.mjs
CHANGED
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
// `script` disagree on both flag names and argument order, and anything we
|
|
20
20
|
// cannot positively identify falls back to today's plain `inherit`.
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
22
|
-
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
22
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
23
25
|
import { StringDecoder } from "node:string_decoder";
|
|
26
|
+
import { activeChildSink } from "./mirror.mjs";
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* POSIX single-quote escaping, for argv that has to survive being flattened
|
|
@@ -178,3 +181,57 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) {
|
|
|
178
181
|
if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
|
|
179
182
|
return Boolean(flavor);
|
|
180
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wrap a spawn spec so a copy of everything the child prints reaches `onOutput`
|
|
187
|
+
* while the child still owns the real terminal.
|
|
188
|
+
*
|
|
189
|
+
* The whole capture dance in one place — temp transcript, the flavour-specific
|
|
190
|
+
* `script` argv, the follower, the banner strip, the cleanup — because every
|
|
191
|
+
* launcher in the pit needs it, and each one growing its own copy is how a
|
|
192
|
+
* shell command ended up invisible in the mirror while `/agents claude` was
|
|
193
|
+
* captured: both spawn `inherit`, and only one of them had been taught this.
|
|
194
|
+
*
|
|
195
|
+
* `onOutput` defaults to whatever the live mirror is (src/mirror.mjs), so a
|
|
196
|
+
* launcher gets capture without having to know the mirror exists — the reverse
|
|
197
|
+
* of how this started, where each launcher had to be taught separately and only
|
|
198
|
+
* two ever were. Pass `null` to opt a launch out.
|
|
199
|
+
*
|
|
200
|
+
* Returns `{ cmd, args, stop }`. With nothing watching, or on a box with no
|
|
201
|
+
* `script(1)` we can drive, `cmd`/`args` come back exactly as passed in and
|
|
202
|
+
* `stop` is a no-op — the caller spawns what it always spawned. `stop()` must
|
|
203
|
+
* be called once the child exits: it drains the tail of the transcript (the
|
|
204
|
+
* last lines of a command are usually the ones you were waiting for) and
|
|
205
|
+
* removes the temp dir.
|
|
206
|
+
*/
|
|
207
|
+
export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { flavor = scriptFlavor() } = {}) {
|
|
208
|
+
const plain = { cmd, args, stop: () => {} };
|
|
209
|
+
if (!ptyEnabled(onOutput, flavor)) return plain;
|
|
210
|
+
let workDir = null;
|
|
211
|
+
try {
|
|
212
|
+
workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
|
|
213
|
+
const transcript = path.join(workDir, "transcript");
|
|
214
|
+
writeFileSync(transcript, "");
|
|
215
|
+
const wrapped = ptySpec(cmd, args, transcript, flavor);
|
|
216
|
+
if (!wrapped) throw new Error("no script(1) spec for this flavour");
|
|
217
|
+
let first = true;
|
|
218
|
+
const stopFollow = followFile(transcript, (chunk) => {
|
|
219
|
+
const clean = stripScriptBanner(chunk, first);
|
|
220
|
+
first = false;
|
|
221
|
+
if (clean) onOutput(clean);
|
|
222
|
+
});
|
|
223
|
+
const dir = workDir;
|
|
224
|
+
return {
|
|
225
|
+
cmd: wrapped.cmd,
|
|
226
|
+
args: wrapped.args,
|
|
227
|
+
stop() {
|
|
228
|
+
try { stopFollow(); } catch { /* nothing left to drain */ }
|
|
229
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
} catch {
|
|
233
|
+
// Capture is a nicety; never let it stop a command from running.
|
|
234
|
+
if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
|
|
235
|
+
return plain;
|
|
236
|
+
}
|
|
237
|
+
}
|
package/src/tui.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
|
|
|
18
18
|
import { loginAuto, whoami, logout } from "./auth.mjs";
|
|
19
19
|
import { startAutoSync } from "./autosync.mjs";
|
|
20
20
|
import { loadCommand, saveCommand } from "./settings-sync.mjs";
|
|
21
|
-
import { createMirror, pressKey, teeOutput } from "./mirror.mjs";
|
|
21
|
+
import { createMirror, pressKey, setActiveSink, teeOutput } from "./mirror.mjs";
|
|
22
22
|
import { fetchMotdAd } from "./ads.mjs";
|
|
23
23
|
import { runScript } from "./runtime.mjs";
|
|
24
24
|
import { moshVocabulary } from "./commands.mjs";
|
|
@@ -28,6 +28,7 @@ import { cryptoCommand } from "./crypto.mjs";
|
|
|
28
28
|
import { gamesCommand } from "./games.mjs";
|
|
29
29
|
import { canOpenBrowser, openBrowser } from "./open-url.mjs";
|
|
30
30
|
import { shellInvocation } from "./shell.mjs";
|
|
31
|
+
import { captureSpec } from "./pty.mjs";
|
|
31
32
|
import { needsRootHere, primeEscalation } from "./escalate.mjs";
|
|
32
33
|
import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
|
|
33
34
|
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
|
|
@@ -678,14 +679,23 @@ async function openWorkflowTool(key, tool, args) {
|
|
|
678
679
|
// command string → `$SHELL +m -ic "<cmd>"` (one-off). Interactive so the command
|
|
679
680
|
// can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
|
|
680
681
|
// that is not optional. Resolves { ok, code, signal }.
|
|
681
|
-
|
|
682
|
+
//
|
|
683
|
+
// Captured through a pty when the mirror is watching, for the same reason an
|
|
684
|
+
// engine is: a shell command is where most of what a pit does actually happens
|
|
685
|
+
// — `!cmd`, /shell, and every shell-valued /alias land here — and with plain
|
|
686
|
+
// `inherit` none of its bytes, on stdout or stderr, ever pass through this
|
|
687
|
+
// process. The session page was showing the echoed command line and the exit
|
|
688
|
+
// note with nothing in between.
|
|
689
|
+
export function runShell(rawCmd, { onOutput } = {}) {
|
|
682
690
|
return new Promise((resolve) => {
|
|
683
691
|
const { shell, args } = shellInvocation(rawCmd);
|
|
692
|
+
const launch = captureSpec({ cmd: shell, args }, onOutput);
|
|
693
|
+
const done = (result) => { try { launch.stop(); } catch { /* already drained */ } resolve(result); };
|
|
684
694
|
let child;
|
|
685
|
-
try { child = spawn(
|
|
686
|
-
catch (e) {
|
|
687
|
-
child.on("error", (e) =>
|
|
688
|
-
child.on("exit", (code, signal) =>
|
|
695
|
+
try { child = spawn(launch.cmd, launch.args, { stdio: "inherit" }); }
|
|
696
|
+
catch (e) { done({ ok: false, error: e }); return; }
|
|
697
|
+
child.on("error", (e) => done({ ok: false, error: e }));
|
|
698
|
+
child.on("exit", (code, signal) => done({ ok: true, code, signal }));
|
|
689
699
|
});
|
|
690
700
|
}
|
|
691
701
|
|
|
@@ -700,7 +710,7 @@ async function openShell(rawCmd) {
|
|
|
700
710
|
? `${bone(shellName)} ${ash(flags)} ${ash(rawCmd)}`
|
|
701
711
|
: `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
|
|
702
712
|
console.log(hr());
|
|
703
|
-
const r = await runShell(rawCmd);
|
|
713
|
+
const r = await runShell(rawCmd, { onOutput: childSink() });
|
|
704
714
|
console.log(hr());
|
|
705
715
|
if (!r.ok) {
|
|
706
716
|
console.log(err(`couldn't start shell: ${r.error?.message || r.error}`));
|
|
@@ -720,14 +730,22 @@ function installTarget(key) {
|
|
|
720
730
|
// something the installer's output scrolled into view.
|
|
721
731
|
if (needsRootHere(target)) primeEscalation({ what: key, out: (s) => console.log(info(s.replace(/^· /, ""))) });
|
|
722
732
|
console.log(hr());
|
|
723
|
-
|
|
733
|
+
// Installers are long, chatty, and the thing you most want to read from a
|
|
734
|
+
// phone — so they go through the mirror's pty like everything else.
|
|
735
|
+
const launch = captureSpec(
|
|
736
|
+
{ cmd: target.install.cmd, args: target.install.args },
|
|
737
|
+
childSink(),
|
|
738
|
+
);
|
|
739
|
+
const child = spawn(launch.cmd, launch.args, { stdio: "inherit" });
|
|
724
740
|
child.on("error", (e) => {
|
|
741
|
+
launch.stop();
|
|
725
742
|
console.log(hr());
|
|
726
743
|
console.log(err(`install failed: ${e.message}`));
|
|
727
744
|
if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp));
|
|
728
745
|
resolve();
|
|
729
746
|
});
|
|
730
747
|
child.on("exit", (code) => {
|
|
748
|
+
launch.stop();
|
|
731
749
|
console.log(hr());
|
|
732
750
|
if (code !== 0) { console.log(err(`install exited ${code}`)); return resolve(); }
|
|
733
751
|
console.log(ok(`${key} installed. 🤘`));
|
|
@@ -1292,6 +1310,10 @@ async function startMirror() {
|
|
|
1292
1310
|
if (!started) return noop;
|
|
1293
1311
|
|
|
1294
1312
|
activeMirror = mirror;
|
|
1313
|
+
// Every launcher that spawns a child reads this rather than being handed a
|
|
1314
|
+
// sink, so a command run from the pit is captured whether or not whoever
|
|
1315
|
+
// wrote that launcher knew the mirror existed.
|
|
1316
|
+
setActiveSink((chunk) => activeMirror?.write(chunk));
|
|
1295
1317
|
const restoreTee = teeOutput((chunk) => mirror.write(chunk));
|
|
1296
1318
|
|
|
1297
1319
|
// Commands arrive whenever; the prompt is only ready between engine
|
|
@@ -1326,6 +1348,7 @@ async function startMirror() {
|
|
|
1326
1348
|
async function stopMirror(restoreTee) {
|
|
1327
1349
|
const mirror = activeMirror;
|
|
1328
1350
|
activeMirror = null;
|
|
1351
|
+
setActiveSink(null);
|
|
1329
1352
|
try { restoreTee?.(); } catch { /* noop */ }
|
|
1330
1353
|
try { await mirror?.stop(); } catch { /* best effort */ }
|
|
1331
1354
|
}
|