mulmoterminal 1.9.1 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -371,7 +371,7 @@ the server runs without one.
371
371
  | ------------ | -------------- | ----------- |
372
372
  | `PORT` | `34567` | Backend HTTP/WebSocket port (prod: the URL you open). |
373
373
  | `CLIENT_PORT` | `6856` | Vite dev-server port (dev only: the URL you open with `yarn dev`). |
374
- | `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. On Windows a bare name is resolved to the `.exe` on `PATH` before it reaches the PTY layer, which matches file names exactly. |
374
+ | `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. On Windows a bare name is resolved on `PATH` before it reaches the PTY layer (which matches file names exactly): to the `.exe` when there is one, otherwise to the `.cmd` shim an npm-global install leaves, run through `cmd.exe`. |
375
375
  | `CLAUDE_CWD` | current dir | Working directory each `claude` PTY runs in; determines which project's sessions the sidebar lists. Via `npx mulmoterminal` it defaults to the directory you ran the command from (override with `--cwd <dir>`, relative allowed); when the server is run directly it falls back to `~/mulmoclaude`. A value read from `.env` must be an absolute path (`~` is not expanded). |
376
376
  | `CLAUDE_PERMISSION_MODE` | `auto` | Permission mode passed to each `claude` spawn. |
377
377
  | `MT_TITLE_MODEL` | `haiku` | Model used for the cell header's AI title (a cheap/fast model summarizing the recent turns). Accepts a `--model` alias or a full model id. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoterminal",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "MulmoTerminal — browser terminal + GUI panel for Claude Code. One command to start.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,48 @@
1
+ // Building the command line that runs a Windows batch shim (`claude.cmd` from an npm-global
2
+ // install) under `cmd.exe`. CreateProcessW — what node-pty ultimately calls — executes PE
3
+ // images only, so a batch target has no other way in (#798).
4
+ //
5
+ // The command line is then parsed TWICE: by cmd.exe, and by the child's CRT. The two
6
+ // disagree about escaping, and cmd's rules are the ones that decide whether an argument
7
+ // stays an argument: `\"` (the CRT's escape, and what node-pty's own argsToCommandLine
8
+ // emits) does not escape a quote for cmd — it ends the quoted run, after which a `&` in the
9
+ // same argument is a command separator. Hence the rules below, which are cmd's.
10
+
11
+ /** An argument that cannot be represented on a Windows command line. Thrown rather than
12
+ * silently mangled: a truncated argument reaches the agent as a DIFFERENT instruction. */
13
+ export class UnsafeArgumentError extends Error {
14
+ constructor(reason: string) {
15
+ super(reason);
16
+ this.name = "UnsafeArgumentError";
17
+ }
18
+ }
19
+
20
+ // A command line has no encoding for these: CR/LF end it, NUL terminates it.
21
+ const UNREPRESENTABLE = /[\0\r\n]/;
22
+
23
+ // Counted rather than matched: an anchored `\\+$` backtracks over a long run.
24
+ const trailingBackslashCount = (text: string): number => {
25
+ const fromEnd = [...text].reverse().findIndex((ch) => ch !== "\\");
26
+ return fromEnd === -1 ? text.length : fromEnd;
27
+ };
28
+
29
+ /** One argument, quoted for cmd.exe. Always quoted — inside quotes cmd's metacharacters
30
+ * (`& | < > ^ ( )`) are literal, which is the whole defence. Internal quotes are doubled
31
+ * (cmd's escape), and a trailing backslash run is doubled so it escapes itself instead of
32
+ * the closing quote when the CRT parses the same text afterwards. */
33
+ export function escapeBatchArgument(arg: string): string {
34
+ if (UNREPRESENTABLE.test(arg)) throw new UnsafeArgumentError(`argument contains a NUL, CR or LF: ${JSON.stringify(arg)}`);
35
+ const quotesDoubled = arg.replace(/"/g, '""');
36
+ return `"${quotesDoubled}${"\\".repeat(trailingBackslashCount(quotesDoubled))}"`;
37
+ }
38
+
39
+ /** Everything after `cmd.exe` for running `batchPath args…`.
40
+ *
41
+ * `/d` skips AutoRun, so a `HKCU\…\Command Processor\AutoRun` command cannot run inside our
42
+ * session first. `/s` makes cmd strip exactly the outer quote pair and take the rest
43
+ * verbatim, instead of applying its "is the first token a quoted path?" heuristics to a line
44
+ * that already contains quoted arguments. */
45
+ export function batchCommandLine(batchPath: string, args: readonly string[]): string {
46
+ const command = [escapeBatchArgument(batchPath), ...args.map(escapeBatchArgument)].join(" ");
47
+ return `/d /s /c "${command}"`;
48
+ }
@@ -37,14 +37,17 @@ export function isPathVar(name: string): boolean {
37
37
  return name.toLowerCase() === "path";
38
38
  }
39
39
 
40
- // The search path out of a PLAIN env object. `env.PATH` is only reliable on the live
41
- // `process.env`, whose Windows lookups are case-insensitive; a copy (what sanitizePtyEnv
42
- // returns) keeps the original "Path" key and answers undefined to `.PATH`.
43
- export function pathFromEnv(env: NodeJS.ProcessEnv): string | undefined {
44
- const entry = Object.entries(env).find(([name]) => isPathVar(name));
45
- return entry?.[1];
40
+ // A variable out of a PLAIN env object, matched the way Windows matches: case-insensitively.
41
+ // `env.PATH` / `env.ComSpec` are only reliable on the live `process.env`, whose Windows
42
+ // lookups are case-insensitive; a copy (what sanitizePtyEnv returns) keeps whatever casing
43
+ // the system used and answers undefined to any other spelling.
44
+ export function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
45
+ const wanted = name.toLowerCase();
46
+ return Object.entries(env).find(([key]) => key.toLowerCase() === wanted)?.[1];
46
47
  }
47
48
 
49
+ export const pathFromEnv = (env: NodeJS.ProcessEnv): string | undefined => envValue(env, "PATH");
50
+
48
51
  // yarn v1's temp dir is `yarn--` + a timestamp.
49
52
  const YARN_SHIM_DIR = /^yarn--\d/;
50
53
 
@@ -4,28 +4,33 @@
4
4
  // compares each PATH directory's file name EXACTLY — it never appends an executable
5
5
  // extension. A bare "claude" therefore misses `…\.local\bin\claude.exe` and the spawn dies
6
6
  // with `File not found: ` (nothing after the colon, since the path it failed to find is the
7
- // empty string). Handing it an absolute path skips that lookup entirely.
7
+ // empty string). Handing it an absolute path skips that lookup entirely (#794).
8
8
  //
9
- // The gate is all this has to satisfy: node-pty then launches via
10
- // `CreateProcessW(nullptr, cmdline, …)`, which does its own PATH search. Which is also why
11
- // only a PE image may be returned here CreateProcess cannot run a `.cmd`, a `.bat`, or an
12
- // extensionless shell shim, so resolving a bare name to one would turn a spawn that works
13
- // today (the shim satisfies the gate, CreateProcess finds the real .exe elsewhere on PATH)
14
- // into `Cannot create process`.
9
+ // node-pty then launches via `CreateProcessW(nullptr, cmdline, …)`, which runs PE images
10
+ // only. A batch shim all an npm-global install leaves on PATH therefore has to go
11
+ // through `cmd.exe` instead of being named directly (#798), which is why this resolves to a
12
+ // launch descriptor rather than to a path.
15
13
  import { statSync } from "node:fs";
16
14
  import path from "node:path";
17
- import { pathFromEnv } from "./pty-env.js";
15
+ import { envValue, pathFromEnv } from "./pty-env.js";
16
+ import { batchCommandLine } from "./cmd-escape.js";
18
17
 
19
18
  // `.exe` first: with both present, a bare name would have reached the `.exe` (CreateProcess
20
19
  // appends exactly that), so resolving must not silently switch which file runs.
21
20
  const EXECUTABLE_EXTENSIONS = [".exe", ".com"] as const;
21
+ const BATCH_EXTENSIONS = [".cmd", ".bat"] as const;
22
22
 
23
- const hasExecutableExtension = (bin: string): boolean => EXECUTABLE_EXTENSIONS.some((ext) => bin.toLowerCase().endsWith(ext));
23
+ const endsWithOneOf = (bin: string, extensions: readonly string[]): boolean => extensions.some((ext) => bin.toLowerCase().endsWith(ext));
24
24
 
25
25
  // A name node-pty/CreateProcess already resolves on its own (absolute, or relative to a
26
26
  // directory) rather than by searching PATH.
27
27
  const namesAPath = (bin: string): boolean => bin.includes("\\") || bin.includes("/");
28
28
 
29
+ // What node-pty is handed. `args` as a STRING is a raw command line that node-pty passes
30
+ // through verbatim (its argsToCommandLine only quotes an argv ARRAY) — which is what lets
31
+ // the cmd.exe wrapping below own its own quoting.
32
+ export type PtyLaunch = { file: string; args: string[] | string };
33
+
29
34
  export function isExecutableFile(candidate: string): boolean {
30
35
  try {
31
36
  return statSync(candidate).isFile();
@@ -34,17 +39,6 @@ export function isExecutableFile(candidate: string): boolean {
34
39
  }
35
40
  }
36
41
 
37
- /** The absolute path of the executable a bare Windows command name refers to, or null when
38
- * PATH holds no `.exe`/`.com` for it (the caller then leaves the name as it is). Pure —
39
- * `searchPath` and the existence check are parameters, and `path.win32` + ";" are used
40
- * explicitly, so the rule is checkable from a POSIX host too. */
41
- export function resolveWindowsExecutable(bin: string, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
42
- if (bin === "" || namesAPath(bin)) return null;
43
- const names = hasExecutableExtension(bin) ? [bin] : EXECUTABLE_EXTENSIONS.map((ext) => bin + ext);
44
- const candidates = searchDirectories(searchPath).flatMap((dir) => names.map((name) => path.win32.join(dir, name)));
45
- return candidates.find(fileExists) ?? null; // `find` is lazy: it stops at the first hit
46
- }
47
-
48
42
  // A PATH entry may be quoted — `"C:\Program Files\tools"` — which the shells strip and a
49
43
  // plain join would not, leaving a path that matches nothing.
50
44
  const searchDirectories = (searchPath: string | undefined): string[] =>
@@ -53,15 +47,72 @@ const searchDirectories = (searchPath: string | undefined): string[] =>
53
47
  .map((entry) => entry.replace(/^"(.*)"$/, "$1"))
54
48
  .filter((entry) => entry !== "");
55
49
 
56
- /** The name to hand node-pty for `bin`. Unchanged off Windows, and unchanged when nothing
57
- * resolves a host that spawns fine today must keep spawning fine. */
58
- export function resolvePtyBin(bin: string, platform: NodeJS.Platform, searchPath: string | undefined, fileExists = isExecutableFile): string {
59
- if (platform !== "win32") return bin;
60
- return resolveWindowsExecutable(bin, searchPath, fileExists) ?? bin;
50
+ // The first `<dir>\<bin><ext>` that exists, over every PATH directory for one set of
51
+ // extensions. `find` is lazy, so it stops at the first hit.
52
+ function searchPathFor(bin: string, extensions: readonly string[], searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
53
+ const names = endsWithOneOf(bin, extensions) ? [bin] : extensions.map((ext) => bin + ext);
54
+ return (
55
+ searchDirectories(searchPath)
56
+ .flatMap((dir) => names.map((name) => path.win32.join(dir, name)))
57
+ .find(fileExists) ?? null
58
+ );
59
+ }
60
+
61
+ /** The absolute path of the executable a bare Windows command name refers to, or null when
62
+ * PATH holds no `.exe`/`.com` for it. Pure — `searchPath` and the existence check are
63
+ * parameters, and `path.win32` + ";" are used explicitly, so the rule is checkable from a
64
+ * POSIX host too. */
65
+ export function resolveWindowsExecutable(bin: string, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
66
+ if (bin === "" || namesAPath(bin)) return null;
67
+ return searchPathFor(bin, EXECUTABLE_EXTENSIONS, searchPath, fileExists);
68
+ }
69
+
70
+ /** The absolute path of the batch shim a bare command name refers to, or null. Only ever
71
+ * consulted after `resolveWindowsExecutable` has come up empty ACROSS THE WHOLE PATH —
72
+ * deliberately not cmd.exe's per-directory order. An install whose shim sits in an earlier
73
+ * PATH directory than its real `.exe` runs the `.exe` today; moving it onto the cmd.exe
74
+ * path would add a parsing layer to a spawn that already works. */
75
+ export function resolveWindowsBatch(bin: string, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
76
+ if (bin === "" || namesAPath(bin)) return null;
77
+ return searchPathFor(bin, BATCH_EXTENSIONS, searchPath, fileExists);
78
+ }
79
+
80
+ /** How to launch `bin args…`. Off Windows, and whenever nothing resolves, this is the
81
+ * arguments unchanged — a host that spawns fine today must keep spawning fine. */
82
+ export function resolvePtyLaunch(
83
+ bin: string,
84
+ args: string[],
85
+ platform: NodeJS.Platform,
86
+ searchPath: string | undefined,
87
+ comspec: string | undefined,
88
+ fileExists = isExecutableFile,
89
+ ): PtyLaunch {
90
+ if (platform !== "win32") return { file: bin, args };
91
+ // A name that already names a path needs no PATH search — but CreateProcess still cannot
92
+ // RUN a batch file, and `CLAUDE_BIN=C:\…\claude.cmd` is exactly what someone reaches for
93
+ // when working around a broken spawn. Wrapped without an existence check: a relative path
94
+ // is relative to the PTY's cwd, not this process's, so checking here would ask the wrong
95
+ // directory — and cmd.exe naming the path it cannot find beats node-pty's empty message.
96
+ if (namesAPath(bin)) {
97
+ if (!endsWithOneOf(bin, BATCH_EXTENSIONS)) return { file: bin, args };
98
+ return { file: commandProcessor(comspec, searchPath, fileExists), args: batchCommandLine(bin, args) };
99
+ }
100
+ const executable = resolveWindowsExecutable(bin, searchPath, fileExists);
101
+ if (executable) return { file: executable, args };
102
+ const batch = resolveWindowsBatch(bin, searchPath, fileExists);
103
+ if (batch) return { file: commandProcessor(comspec, searchPath, fileExists), args: batchCommandLine(batch, args) };
104
+ return { file: bin, args };
105
+ }
106
+
107
+ // ComSpec is where Windows itself records the command processor; falling back to a PATH
108
+ // lookup (and then to the bare name) keeps a stripped environment working.
109
+ function commandProcessor(comspec: string | undefined, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string {
110
+ if (comspec && namesAPath(comspec) && fileExists(comspec)) return comspec;
111
+ return resolveWindowsExecutable("cmd.exe", searchPath, fileExists) ?? "cmd.exe";
61
112
  }
62
113
 
63
- /** `resolvePtyBin` against the environment the session itself will run with: the binary that
64
- * matters is the one reachable from the child's PATH, not the server's. */
65
- export function resolvePtyBinForEnv(bin: string, env: NodeJS.ProcessEnv): string {
66
- return resolvePtyBin(bin, process.platform, pathFromEnv(env));
114
+ /** `resolvePtyLaunch` against the environment the session itself will run with: the binary
115
+ * that matters is the one reachable from the child's PATH, not the server's. */
116
+ export function resolvePtyLaunchForEnv(bin: string, args: string[], env: NodeJS.ProcessEnv): PtyLaunch {
117
+ return resolvePtyLaunch(bin, args, process.platform, pathFromEnv(env), envValue(env, "ComSpec"));
67
118
  }
@@ -7,7 +7,7 @@ import type { IPty } from "node-pty";
7
7
  import path from "node:path";
8
8
  import type { WebSocket } from "ws";
9
9
  import { sanitizePtyEnv } from "../infra/pty-env.js";
10
- import { resolvePtyBinForEnv } from "../infra/resolve-bin.js";
10
+ import { resolvePtyLaunchForEnv } from "../infra/resolve-bin.js";
11
11
  import { withoutUnset } from "./provider-env.js";
12
12
  import { tmuxAvailable, tmuxNewSessionArgs, tmuxScrubEnvNames } from "../infra/tmux.js";
13
13
  import {
@@ -34,9 +34,11 @@ const PTY_ROWS = 30;
34
34
  // the settings `env` block, which can set a variable but not remove one.
35
35
  export function spawnPty(bin: string, args: string[], cwd: string, unset: readonly string[] = []): IPty {
36
36
  const env = withoutUnset(sanitizePtyEnv(process.env, path.delimiter), unset);
37
- // On Windows a bare name never reaches node-pty as-is: its own PATH lookup ignores
38
- // executable extensions, so `claude` misses claude.exe (see infra/resolve-bin.ts, #794).
39
- return pty.spawn(resolvePtyBinForEnv(bin, env), args, { name: "xterm-256color", cols: PTY_COLS, rows: PTY_ROWS, cwd, env });
37
+ // On Windows neither the name nor the arguments reach node-pty as they are: its PATH
38
+ // lookup ignores executable extensions (so `claude` misses claude.exe, #794), and a batch
39
+ // shim has to be run through cmd.exe (#798). See infra/resolve-bin.ts.
40
+ const launch = resolvePtyLaunchForEnv(bin, args, env);
41
+ return pty.spawn(launch.file, launch.args, { name: "xterm-256color", cols: PTY_COLS, rows: PTY_ROWS, cwd, env });
40
42
  }
41
43
 
42
44
  // Would this session run in the Docker sandbox? Single-view interactive only (the caller