moshcode 0.75.0 → 0.77.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/dns-system.mjs +23 -5
- package/src/engines.mjs +20 -3
- package/src/mirror.mjs +35 -4
- package/src/pty.mjs +239 -9
- package/src/rates.mjs +1 -1
- package/src/tui.mjs +13 -2
package/package.json
CHANGED
package/src/dns-system.mjs
CHANGED
|
@@ -362,14 +362,32 @@ export async function readPid(path = pidfilePath()) {
|
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
/**
|
|
366
|
-
|
|
365
|
+
/**
|
|
366
|
+
* Is that pid alive? A stale pidfile must not read as running.
|
|
367
|
+
*
|
|
368
|
+
* "Alive" and "ours" are different questions, and answering the first with the
|
|
369
|
+
* second cost a machine its resolver. `process.kill(pid, 0)` fails two ways:
|
|
370
|
+
* ESRCH for a pid that is gone, and EPERM for one that is there but belongs to
|
|
371
|
+
* another user. Catching both as "dead" was wrong in precisely the case this
|
|
372
|
+
* tool manufactures — `dns enable` escalates, so the bridge is root's while
|
|
373
|
+
* every later `status` asking after it is not.
|
|
374
|
+
*
|
|
375
|
+
* A live root-owned bridge therefore read as a stale pidfile for the rest of
|
|
376
|
+
* its life: `stop` deleted the file and reported it cleared while the daemon
|
|
377
|
+
* kept running, and `start` saw nothing there and put a second bridge on
|
|
378
|
+
* 127.0.0.1 underneath the working one on 0.0.0.0 — the shadowing outage
|
|
379
|
+
* `bridgePresence` describes, arrived at by believing our own liveness check.
|
|
380
|
+
*
|
|
381
|
+
* So only ESRCH is dead. EPERM is alive and someone else's, which the caller
|
|
382
|
+
* needs told rather than papered over.
|
|
383
|
+
*/
|
|
384
|
+
export function isAlive(pid, kill = (target) => process.kill(target, 0)) {
|
|
367
385
|
if (!pid) return false;
|
|
368
386
|
try {
|
|
369
|
-
|
|
387
|
+
kill(pid);
|
|
370
388
|
return true;
|
|
371
|
-
} catch {
|
|
372
|
-
return
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return error?.code === "EPERM";
|
|
373
391
|
}
|
|
374
392
|
}
|
|
375
393
|
|
package/src/engines.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
36
36
|
import { homedir } from "node:os";
|
|
37
37
|
import path from "node:path";
|
|
38
38
|
|
|
39
|
+
import { setActiveChildInput } from "./mirror.mjs";
|
|
39
40
|
import { captureSpec } from "./pty.mjs";
|
|
40
41
|
|
|
41
42
|
export const ENGINES = {
|
|
@@ -509,11 +510,27 @@ export function openPassthrough(target, args = [], { onOutput } = {}) {
|
|
|
509
510
|
// the child the tty's own file descriptors, so none of its bytes ever pass
|
|
510
511
|
// through this process. See src/pty.mjs for why this is script(1) and not
|
|
511
512
|
// a pipe or node-pty.
|
|
512
|
-
|
|
513
|
-
|
|
513
|
+
//
|
|
514
|
+
// `input` asks for the same pty to be one we can type into. An engine is
|
|
515
|
+
// the whole reason it exists: it puts up menus and trust prompts that only
|
|
516
|
+
// move for a keypress, and until we owned its stdin a session page could
|
|
517
|
+
// watch one of those appear and had no way to answer it.
|
|
518
|
+
const launch = captureSpec(spec, onOutput, { input: true });
|
|
519
|
+
// Route web keys here for as long as this child is up, and only when there
|
|
520
|
+
// is really somewhere for them to go — an unmirrored pit, or a box with no
|
|
521
|
+
// `script(1)`, still runs the plain inherited launch, and saying otherwise
|
|
522
|
+
// would have pressKey silently swallow keys the pit could have handled.
|
|
523
|
+
// Registered before the spawn on purpose: the fifo buffers, so a key that
|
|
524
|
+
// arrives while the engine is still starting is delivered, not dropped.
|
|
525
|
+
const typeable = launch.stdio !== "inherit";
|
|
526
|
+
if (typeable) setActiveChildInput(launch.write);
|
|
527
|
+
const cleanup = () => {
|
|
528
|
+
if (typeable) setActiveChildInput(null);
|
|
529
|
+
launch.stop();
|
|
530
|
+
};
|
|
514
531
|
|
|
515
532
|
let child;
|
|
516
|
-
try { child = spawn(launch.cmd, launch.args, { stdio:
|
|
533
|
+
try { child = spawn(launch.cmd, launch.args, { stdio: launch.stdio, env }); }
|
|
517
534
|
catch (e) { cleanup(); resolve({ ok: false, error: e }); return; }
|
|
518
535
|
child.on("error", (e) => { cleanup(); resolve({ ok: false, error: e }); });
|
|
519
536
|
child.on("exit", (code, signal) => { cleanup(); resolve({ ok: true, code, signal }); });
|
package/src/mirror.mjs
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// every network call is swallowed, because a flaky link must never take down
|
|
7
7
|
// the terminal you're actually working in.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// A child that takes the terminal (`/agents claude`) writes straight to the tty
|
|
10
|
+
// on its own fd, so none of its bytes pass through this process on their own.
|
|
11
|
+
// Both directions are handled in src/pty.mjs instead: its output is copied out
|
|
12
|
+
// of a pty transcript, and its stdin is a fifo we hold, which is what lets a key
|
|
13
|
+
// pressed on the session page answer an engine's prompt rather than land in the
|
|
14
|
+
// pit behind it.
|
|
12
15
|
import os from "node:os";
|
|
13
16
|
import { loadCreds } from "./auth.mjs";
|
|
14
17
|
|
|
@@ -30,7 +33,7 @@ export function decodeKey(body) {
|
|
|
30
33
|
|
|
31
34
|
// What each key looks like to a program reading the tty in raw mode, and the
|
|
32
35
|
// keypress readline wants when it is the one holding the line.
|
|
33
|
-
const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" };
|
|
36
|
+
export const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" };
|
|
34
37
|
const KEY_PRESS = {
|
|
35
38
|
up: { name: "up" }, down: { name: "down" }, right: { name: "right" },
|
|
36
39
|
left: { name: "left" }, enter: { name: "return" },
|
|
@@ -43,6 +46,14 @@ const KEY_PRESS = {
|
|
|
43
46
|
export function pressKey(name, rl = null, stdin = process.stdin) {
|
|
44
47
|
const bytes = KEY_BYTES[name];
|
|
45
48
|
if (!bytes) return false;
|
|
49
|
+
// A child engine takes precedence over everything below, because when one is
|
|
50
|
+
// running it is the thing the person on the session page can see. It reads a
|
|
51
|
+
// real file descriptor rather than this process's stdin object, so the bytes
|
|
52
|
+
// have to be *written* — the synthesised event further down reaches readline
|
|
53
|
+
// and the pit's own raw-mode readers, and nothing that was spawned. This is
|
|
54
|
+
// the line that decides whether an arrow key lands on Claude's trust prompt.
|
|
55
|
+
const toChild = activeChildInput();
|
|
56
|
+
if (toChild && toChild(bytes)) return true;
|
|
46
57
|
// At the prompt readline owns the line editor, so hand it a keypress rather
|
|
47
58
|
// than bytes: ↑/↓ walk the history, ←/→ move within the line, enter runs it.
|
|
48
59
|
if (rl) {
|
|
@@ -78,6 +89,26 @@ export function activeChildSink() {
|
|
|
78
89
|
return activeSink;
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
// The other direction: where to put bytes so the program currently holding the
|
|
93
|
+
// terminal reads them.
|
|
94
|
+
//
|
|
95
|
+
// Null almost always, and set only while a child owns the tty under a pty we
|
|
96
|
+
// opened (src/pty.mjs captureWithInput). It has to be module-level for the same
|
|
97
|
+
// reason the sink does — pressKey is called from the mirror's poll loop, which
|
|
98
|
+
// has no idea which launcher is mid-flight — and it is what makes a key pressed
|
|
99
|
+
// on the session page land in an engine rather than in the pit behind it.
|
|
100
|
+
let activeInput = null;
|
|
101
|
+
|
|
102
|
+
/** Point web keystrokes at a running child (or null when it exits). */
|
|
103
|
+
export function setActiveChildInput(write) {
|
|
104
|
+
activeInput = typeof write === "function" ? write : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How to type into whatever child owns the terminal, or null for the pit. */
|
|
108
|
+
export function activeChildInput() {
|
|
109
|
+
return activeInput;
|
|
110
|
+
}
|
|
111
|
+
|
|
81
112
|
export function createMirror({
|
|
82
113
|
version = "",
|
|
83
114
|
cwd = process.cwd(),
|
package/src/pty.mjs
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
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 {
|
|
22
|
+
import {
|
|
23
|
+
closeSync, constants, existsSync, mkdtempSync, openSync,
|
|
24
|
+
readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync,
|
|
25
|
+
} from "node:fs";
|
|
23
26
|
import { tmpdir } from "node:os";
|
|
24
27
|
import path from "node:path";
|
|
25
28
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -78,6 +81,18 @@ export function ptySpec(cmd, args = [], transcript, flavor) {
|
|
|
78
81
|
return null;
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The same thing for a shell *line* rather than an argv, which the input path
|
|
86
|
+
* needs: it prefixes the child with `stty` and `tty` so the session sizes
|
|
87
|
+
* itself and says where it landed, and those only exist as shell.
|
|
88
|
+
*/
|
|
89
|
+
export function ptyShellSpec(command, transcript, flavor) {
|
|
90
|
+
if (!command || !transcript) return null;
|
|
91
|
+
if (flavor === "util-linux") return { cmd: "script", args: ["-q", "-e", "-f", "-c", command, transcript] };
|
|
92
|
+
if (flavor === "bsd") return { cmd: "script", args: ["-q", "-F", transcript, "sh", "-c", command] };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
/**
|
|
82
97
|
* Follow a transcript as it is written, handing each new slice to `onChunk`.
|
|
83
98
|
*
|
|
@@ -197,16 +212,29 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) {
|
|
|
197
212
|
* of how this started, where each launcher had to be taught separately and only
|
|
198
213
|
* two ever were. Pass `null` to opt a launch out.
|
|
199
214
|
*
|
|
200
|
-
* Returns `{ cmd, args, stop }`. With nothing watching, or on a
|
|
201
|
-
* `script(1)` we can drive, `cmd`/`args` come back exactly as
|
|
202
|
-
* `
|
|
203
|
-
* be called once the child exits: it
|
|
204
|
-
* last lines of a command are usually
|
|
205
|
-
* removes the temp dir.
|
|
215
|
+
* Returns `{ cmd, args, stdio, write, stop }`. With nothing watching, or on a
|
|
216
|
+
* box with no `script(1)` we can drive, `cmd`/`args` come back exactly as
|
|
217
|
+
* passed in, `stdio` is "inherit" and `write` returns false — the caller spawns
|
|
218
|
+
* what it always spawned. `stop()` must be called once the child exits: it
|
|
219
|
+
* drains the tail of the transcript (the last lines of a command are usually
|
|
220
|
+
* the ones you were waiting for) and removes the temp dir.
|
|
221
|
+
*
|
|
222
|
+
* `input: true` additionally makes the child's stdin something we can type
|
|
223
|
+
* into, so the session page can drive it — see captureWithInput.
|
|
206
224
|
*/
|
|
207
|
-
export function captureSpec(
|
|
208
|
-
|
|
225
|
+
export function captureSpec(
|
|
226
|
+
{ cmd, args = [] },
|
|
227
|
+
onOutput = activeChildSink(),
|
|
228
|
+
{ flavor = scriptFlavor(), input = false, stdin = process.stdin, stdout = process.stdout } = {},
|
|
229
|
+
) {
|
|
230
|
+
const plain = { cmd, args, stdio: "inherit", write: () => false, stop: () => {} };
|
|
209
231
|
if (!ptyEnabled(onOutput, flavor)) return plain;
|
|
232
|
+
if (input) {
|
|
233
|
+
const withInput = captureWithInput({ cmd, args }, onOutput, { flavor, stdin, stdout });
|
|
234
|
+
if (withInput) return withInput;
|
|
235
|
+
// No fifo, no local tty, nothing we could drive — fall through to the
|
|
236
|
+
// output-only capture rather than dropping capture altogether.
|
|
237
|
+
}
|
|
210
238
|
let workDir = null;
|
|
211
239
|
try {
|
|
212
240
|
workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
|
|
@@ -224,6 +252,8 @@ export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), {
|
|
|
224
252
|
return {
|
|
225
253
|
cmd: wrapped.cmd,
|
|
226
254
|
args: wrapped.args,
|
|
255
|
+
stdio: "inherit",
|
|
256
|
+
write: () => false,
|
|
227
257
|
stop() {
|
|
228
258
|
try { stopFollow(); } catch { /* nothing left to drain */ }
|
|
229
259
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
|
|
@@ -235,3 +265,203 @@ export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), {
|
|
|
235
265
|
return plain;
|
|
236
266
|
}
|
|
237
267
|
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Typing into the child
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/** A terminal geometry we can hand a child, with a sane floor. */
|
|
274
|
+
function geometry(stdout) {
|
|
275
|
+
return { cols: Number(stdout?.columns) || 80, rows: Number(stdout?.rows) || 24 };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Application cursor keys (DECCKM). A program that turns this on is saying "send
|
|
279
|
+
// me ESC O B for down, not ESC [ B", and a terminal obliges — which is why the
|
|
280
|
+
// distinction never comes up for the person at the keyboard, and why it bites
|
|
281
|
+
// the moment we start synthesising keys ourselves. `less` is the plain
|
|
282
|
+
// demonstration: fed the CSI form while it has DECCKM set, it does not scroll,
|
|
283
|
+
// it prints "ESC[B" on its own prompt line as if you had typed the characters.
|
|
284
|
+
//
|
|
285
|
+
// The mode is not something we can ask about, but it is announced: the child
|
|
286
|
+
// writes the escape on its way into full-screen mode, and every byte it writes
|
|
287
|
+
// is already passing under our nose on the way to the mirror.
|
|
288
|
+
const DECCKM = /\u001b\[\?1([hl])/g;
|
|
289
|
+
|
|
290
|
+
/** Track a DECCKM change announced in `text`; returns the mode after it. */
|
|
291
|
+
export function cursorKeyMode(text, current = false) {
|
|
292
|
+
const seen = [...String(text).matchAll(DECCKM)].pop();
|
|
293
|
+
return seen ? seen[1] === "h" : current;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Rewrite CSI cursor keys as SS3, for a child that asked for application mode.
|
|
298
|
+
*
|
|
299
|
+
* Only the four cursor keys move: everything else, including a literal ESC and
|
|
300
|
+
* anything the person at the keyboard typed, is left exactly as it arrived.
|
|
301
|
+
*/
|
|
302
|
+
export function toApplicationCursor(buf) {
|
|
303
|
+
const out = Buffer.from(buf);
|
|
304
|
+
for (let i = 0; i + 2 < out.length; i += 1) {
|
|
305
|
+
// ESC [ A|B|C|D -> ESC O A|B|C|D
|
|
306
|
+
if (out[i] === 0x1b && out[i + 1] === 0x5b && out[i + 2] >= 0x41 && out[i + 2] <= 0x44) {
|
|
307
|
+
out[i + 1] = 0x4f;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The same capture, but with a stdin the mirror can write to.
|
|
315
|
+
*
|
|
316
|
+
* `inherit` hands the child the tty's own file descriptors, which is why a key
|
|
317
|
+
* pressed on the session page could never reach it: there is no fd in this
|
|
318
|
+
* process between the browser and the program, so the best the mirror could do
|
|
319
|
+
* was synthesise a `data` event on its own `process.stdin` — which the pit's
|
|
320
|
+
* readline hears and a child does not (see pressKey in src/mirror.mjs). To type
|
|
321
|
+
* into an engine we have to own its stdin, and a fifo is the one way to do that
|
|
322
|
+
* with nothing but the base system: `script(1)` reads it and copies it to the
|
|
323
|
+
* pty master, exactly as it would a terminal.
|
|
324
|
+
*
|
|
325
|
+
* Owning stdin costs two things back, and both are paid here rather than
|
|
326
|
+
* written off as limitations:
|
|
327
|
+
*
|
|
328
|
+
* - Size. `script` takes the pty's geometry from its own stdin, and a fifo has
|
|
329
|
+
* none, so the child would start on a 0x0 terminal — which full-screen
|
|
330
|
+
* engines do not survive. Nothing outside a pty can ioctl its master, but
|
|
331
|
+
* `stty` inside it can, so the session sizes itself on the way in.
|
|
332
|
+
* - Resize. For the same reason `script` can no longer forward SIGWINCH. The
|
|
333
|
+
* child records its pty path on the way in, which is enough to resize it
|
|
334
|
+
* from out here with `stty -F` when the real window changes, so dragging a
|
|
335
|
+
* window edge still reaches the engine.
|
|
336
|
+
*
|
|
337
|
+
* The person at the keyboard has to keep working throughout, so local stdin is
|
|
338
|
+
* relayed byte-for-byte into the same fifo. That means raw mode: this tty has
|
|
339
|
+
* to stop echoing and stop buffering lines, because the pty on the other end is
|
|
340
|
+
* now the one doing both.
|
|
341
|
+
*
|
|
342
|
+
* Returns null when this box can't do it (no `mkfifo`, no local tty), which
|
|
343
|
+
* leaves the caller on the output-only path it had before.
|
|
344
|
+
*/
|
|
345
|
+
export function captureWithInput({ cmd, args = [] }, onOutput, { flavor, stdin, stdout } = {}) {
|
|
346
|
+
// Without a local terminal there is nothing to relay and raw mode is
|
|
347
|
+
// meaningless, so capture alone is the honest thing to offer.
|
|
348
|
+
if (!stdin?.isTTY || typeof stdin.setRawMode !== "function") return null;
|
|
349
|
+
|
|
350
|
+
let workDir = null;
|
|
351
|
+
let fd = null;
|
|
352
|
+
try {
|
|
353
|
+
workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
|
|
354
|
+
const transcript = path.join(workDir, "transcript");
|
|
355
|
+
const fifo = path.join(workDir, "input");
|
|
356
|
+
const ptsFile = path.join(workDir, "pts");
|
|
357
|
+
writeFileSync(transcript, "");
|
|
358
|
+
|
|
359
|
+
// node has no mkfifo, so this is the one call out to the system — and a box
|
|
360
|
+
// without it simply does not get the input path.
|
|
361
|
+
const made = spawnSync("mkfifo", [fifo]);
|
|
362
|
+
if (made.error || made.status !== 0) throw new Error("no mkfifo on this box");
|
|
363
|
+
|
|
364
|
+
const { cols, rows } = geometry(stdout);
|
|
365
|
+
const command = [
|
|
366
|
+
`tty > ${shQuote(ptsFile)} 2>/dev/null`,
|
|
367
|
+
`stty rows ${rows} cols ${cols} 2>/dev/null`,
|
|
368
|
+
// exec, so the engine *is* the process script is waiting on: its signals
|
|
369
|
+
// and its exit status pass straight through rather than via a shell.
|
|
370
|
+
`exec ${[cmd, ...args].map(shQuote).join(" ")}`,
|
|
371
|
+
].join("; ");
|
|
372
|
+
const wrapped = ptyShellSpec(command, transcript, flavor);
|
|
373
|
+
if (!wrapped) throw new Error("no script(1) spec for this flavour");
|
|
374
|
+
|
|
375
|
+
// O_RDWR, not O_WRONLY: opening a fifo write-only blocks until a reader
|
|
376
|
+
// arrives, and the reader here is a child we have not spawned yet. Holding
|
|
377
|
+
// both ends also keeps the child from seeing EOF between writes.
|
|
378
|
+
fd = openSync(fifo, constants.O_RDWR);
|
|
379
|
+
|
|
380
|
+
let first = true;
|
|
381
|
+
// Which form of cursor key this child is asking for, learned from the same
|
|
382
|
+
// stream that goes to the mirror. Tracked on the raw chunk rather than the
|
|
383
|
+
// banner-stripped one: the mode switch is a control sequence, and nothing
|
|
384
|
+
// about the banner is in its way.
|
|
385
|
+
let appCursor = false;
|
|
386
|
+
const stopFollow = followFile(transcript, (chunk) => {
|
|
387
|
+
appCursor = cursorKeyMode(chunk, appCursor);
|
|
388
|
+
const clean = stripScriptBanner(chunk, first);
|
|
389
|
+
first = false;
|
|
390
|
+
if (clean) onOutput(clean);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
let stopped = false;
|
|
394
|
+
/** Put bytes in front of the child, from the web or from the keyboard. */
|
|
395
|
+
const write = (data) => {
|
|
396
|
+
if (stopped || fd === null) return false;
|
|
397
|
+
const raw = typeof data === "string" ? Buffer.from(data, "latin1") : Buffer.from(data);
|
|
398
|
+
try { writeSync(fd, appCursor ? toApplicationCursor(raw) : raw); return true; }
|
|
399
|
+
catch { return false; }
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Raw, because the pty on the far end is now the one echoing and the one
|
|
403
|
+
// splitting lines. Leaving this tty cooked would double every character and
|
|
404
|
+
// hold Enter back until the child had already redrawn without it.
|
|
405
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
406
|
+
stdin.setRawMode(true);
|
|
407
|
+
stdin.resume();
|
|
408
|
+
const onData = (buf) => { write(buf); };
|
|
409
|
+
stdin.on("data", onData);
|
|
410
|
+
|
|
411
|
+
// The child's own tty, once its prelude has written it down. Read lazily:
|
|
412
|
+
// at the moment we spawn, that file does not exist yet.
|
|
413
|
+
let pts = null;
|
|
414
|
+
const childTty = () => {
|
|
415
|
+
if (pts) return pts;
|
|
416
|
+
try { pts = readFileSync(ptsFile, "utf8").trim() || null; } catch { pts = null; }
|
|
417
|
+
return pts;
|
|
418
|
+
};
|
|
419
|
+
let resizeTimer = null;
|
|
420
|
+
const onResize = () => {
|
|
421
|
+
clearTimeout(resizeTimer);
|
|
422
|
+
// Dragging an edge fires this continuously; settle for one ioctl per drag.
|
|
423
|
+
resizeTimer = setTimeout(() => {
|
|
424
|
+
resizeTimer = null;
|
|
425
|
+
const tty = childTty();
|
|
426
|
+
if (!tty || stopped) return;
|
|
427
|
+
const size = geometry(stdout);
|
|
428
|
+
// -F on util-linux, -f on BSD/macOS — the same disagreement as the
|
|
429
|
+
// script(1) flags above, and getting it wrong here is a usage error on
|
|
430
|
+
// every resize rather than anything visible.
|
|
431
|
+
const on = flavor === "bsd" ? "-f" : "-F";
|
|
432
|
+
try { spawnSync("stty", [on, tty, "rows", String(size.rows), "cols", String(size.cols)]); }
|
|
433
|
+
catch { /* the child owns it; a resize we lose is cosmetic */ }
|
|
434
|
+
}, 120);
|
|
435
|
+
resizeTimer.unref?.();
|
|
436
|
+
};
|
|
437
|
+
stdout?.on?.("resize", onResize);
|
|
438
|
+
|
|
439
|
+
const dir = workDir;
|
|
440
|
+
return {
|
|
441
|
+
cmd: wrapped.cmd,
|
|
442
|
+
args: wrapped.args,
|
|
443
|
+
// The fifo is the child's stdin; its output still goes straight to the
|
|
444
|
+
// real terminal, so the engine draws at full speed exactly as before.
|
|
445
|
+
stdio: [fd, "inherit", "inherit"],
|
|
446
|
+
write,
|
|
447
|
+
stop() {
|
|
448
|
+
if (stopped) return;
|
|
449
|
+
stopped = true;
|
|
450
|
+
clearTimeout(resizeTimer);
|
|
451
|
+
stdout?.off?.("resize", onResize);
|
|
452
|
+
stdin.off("data", onData);
|
|
453
|
+
// Hand the terminal back the way we found it. Getting this wrong leaves
|
|
454
|
+
// the pit with no echo, which reads as a hung shell.
|
|
455
|
+
try { stdin.setRawMode(wasRaw); } catch { /* not a tty any more */ }
|
|
456
|
+
stdin.pause();
|
|
457
|
+
try { stopFollow(); } catch { /* nothing left to drain */ }
|
|
458
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } fd = null; }
|
|
459
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
} catch {
|
|
463
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } }
|
|
464
|
+
if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
package/src/rates.mjs
CHANGED
|
@@ -139,7 +139,7 @@ export function parseRate(spec) {
|
|
|
139
139
|
// A flat fee with no period stated is a project fee, not an hourly one: "$5000
|
|
140
140
|
// for the project" is how it is written, and defaulting it to per-hour would
|
|
141
141
|
// silently multiply the invoice by every hour tracked.
|
|
142
|
-
if (!sawPeriod && rate.unit === "flat" && rate.cap === null) rate.per = "
|
|
142
|
+
if (!sawPeriod && rate.unit === "flat" && rate.cap === null) rate.per = "project";
|
|
143
143
|
if (rate.cap !== null && rate.unit === "flat") {
|
|
144
144
|
throw new Error("upto: caps a unit, so say what it caps — $100/hour/agent/upto:4");
|
|
145
145
|
}
|
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, setActiveSink, teeOutput } from "./mirror.mjs";
|
|
21
|
+
import { activeChildInput, 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";
|
|
@@ -1332,7 +1332,18 @@ async function startMirror() {
|
|
|
1332
1332
|
promptRl.write(`${body}\n`);
|
|
1333
1333
|
}
|
|
1334
1334
|
};
|
|
1335
|
-
mirror.onCommand((body) => {
|
|
1335
|
+
mirror.onCommand((body) => {
|
|
1336
|
+
// An engine has the terminal: send the line to it rather than parking it
|
|
1337
|
+
// for a prompt that will not come back until the engine exits. Without this
|
|
1338
|
+
// the arrow keys could answer a menu but nothing could answer a question,
|
|
1339
|
+
// which is half a session page. Typed straight in, so it arrives the way
|
|
1340
|
+
// the keyboard would deliver it — no `▸ (web)` note, because the engine
|
|
1341
|
+
// echoes it itself and printing over an engine's screen shifts it.
|
|
1342
|
+
const toChild = activeChildInput();
|
|
1343
|
+
if (toChild && toChild(`${body}\r`)) return;
|
|
1344
|
+
queue.push(body);
|
|
1345
|
+
drainRemote();
|
|
1346
|
+
});
|
|
1336
1347
|
|
|
1337
1348
|
// Keys skip the queue: they are pressed the instant they arrive, whether the
|
|
1338
1349
|
// prompt is armed or something else has the tty (a herd bar, the reader, a
|