davinci-resolve-mcp 2.214.1 → 2.214.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,68 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.214.3 — the advanced launcher heals a wrong-Node registration
6
+
7
+ ### Fixed
8
+
9
+ - **`davinci-resolve-advanced-mcp` re-executes itself under a Node ≥ 20.9 when
10
+ started by an older one.** The floor is unchanged and deliberate (sharp's own
11
+ engine floor is 20.9; better-sqlite3 is a native module built for one ABI;
12
+ Node 18 is end-of-life). What kept recurring was the MCP registration's
13
+ `command` landing on an nvm v18 binary after a client app rewrote its config
14
+ (twice on the reference machine), which left the server "disconnected" with
15
+ the fix buried in a log. The launcher now looks for a suitable Node before
16
+ refusing — `DAVINCI_RESOLVE_NODE` first, then nvm's versions directory
17
+ newest-first, then Homebrew/system paths (Windows: Program Files and the
18
+ per-user install) — probes each with `-p process.versions.node`, re-execs
19
+ with the same stdio under the first that passes, and says so on stderr.
20
+ A re-exec marks itself so a bad replacement cannot loop; with no usable
21
+ candidate the refusal now lists what was probed. Measured live: started by
22
+ v18.20.8, it came up under v22.22.3 and served the MCP handshake.
23
+ - **`--node-check`** prints `{node, execPath, reexec}` after the floor check —
24
+ the answer to "which Node is this registration actually running?".
25
+ `--version` and `--help` still answer before the floor, whatever started them.
26
+ - `DAVINCI_RESOLVE_ADVANCED_ASSUME_NODE` fakes the running version and
27
+ `DAVINCI_RESOLVE_ADVANCED_NO_NODE_SEARCH=1` limits the search to the explicit
28
+ override; both exist for the tests and are documented in the launcher.
29
+
30
+ ## What's New in v2.214.2 — a running Resolve is counted by its executable path, not only by its argument vector
31
+
32
+ ### Fixed
33
+
34
+ - **`resolve_control runtime_mode` could report `running: false, instances: 0`
35
+ while Resolve was up and answering scripting calls.** Seen on 2026-09-08
36
+ against Studio 19.1.3.7 at the stock macOS path, in the same minute
37
+ `get_version` connected. The scan read only `ps`'s argument-vector column
38
+ and required the line to *end* in the executable after flag stripping, so
39
+ it had a single point of failure that the exact trigger did not need to be
40
+ known to remove: the kernel withholds argv for some processes (`ps` prints
41
+ `(Resolve)`), a launch argument after the path — a project file — is not a
42
+ flag and defeated the suffix test, and `ps` was not asked for wide output.
43
+ The scan now reads two columns keyed by pid: the executable path (`comm`,
44
+ the full path on macOS, readable whenever the process is) decides whether an
45
+ instance exists; the argument vector decides its mode. An instance whose
46
+ argv cannot be read is counted with `headless: null` — never `false`, since
47
+ a wrong "it has a UI" is what makes an agent wait for a dialog that never
48
+ opens, and the tool's callers consult it before every project switch for
49
+ exactly that reason. `ps` is run with `-ww`. The "a shell line that merely
50
+ names the binary is not an instance" rule is kept and extended to the
51
+ unquoted `sh -c /opt/resolve/bin/resolve -nogui` shape.
52
+ - **The exact 2026-09-08 condition was not reproduced.** The same install,
53
+ restarted, matched the old scan. The fix is a removal of the scan's
54
+ dependence on argv parsing, verified against a fake process table built
55
+ from the real `ps` rows of that machine (pid 39560 at the stock path, its
56
+ IOXPC helper beside it), plus the unreadable-argv, positional-argument,
57
+ wide-output and one-column-failing cases.
58
+
59
+ ### Validation
60
+
61
+ - Seven new tests in `tests/test_headless_runtime.py` pin those cases; the
62
+ existing 29 pass unchanged against the new scan (their bare-command-line
63
+ fake tables are read as both columns of one process). Live on this machine:
64
+ `running: true, instances: 1, headless: false` at the stock path. Full
65
+ offline suite, drift guards and the advanced Node suite green.
66
+
5
67
  ## What's New in v2.214.1 — grade calls fail silently off the Color page; apply_trace_plan switches for you
6
68
 
7
69
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.214.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.214.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.214.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.214.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.214.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.214.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -26,6 +26,7 @@ Usage:
26
26
  davinci-resolve-advanced-mcp
27
27
  davinci-resolve-advanced-mcp --version
28
28
  davinci-resolve-advanced-mcp --help
29
+ davinci-resolve-advanced-mcp --node-check (which Node the server would run under)
29
30
 
30
31
  Starts the offline DaVinci Resolve advanced MCP server over stdio.
31
32
  `;
@@ -49,19 +50,77 @@ if (command === '--version' || command === '-v' || command === 'version') {
49
50
  // command in an MCP registration resolves to on a machine whose shell
50
51
  // default lags. Silent degradation is this repo's least favorite failure
51
52
  // mode; refuse loudly with the fix instead.
52
- const [major, minor] = process.versions.node.split('.').map(Number);
53
+ //
54
+ // Self-heal (2026-09-09, after the registration landed on v18 for the second
55
+ // time): before refusing, look for a Node >=20.9 on this machine (an explicit
56
+ // DAVINCI_RESOLVE_NODE, nvm's versions dir newest-first, Homebrew/system
57
+ // paths), re-exec this launcher under it with the same stdio, and say so on
58
+ // stderr. The registration can then point at any node and still come up.
59
+ // DAVINCI_RESOLVE_ADVANCED_ASSUME_NODE fakes the running version (tests);
60
+ // DAVINCI_RESOLVE_ADVANCED_NO_NODE_SEARCH=1 limits the search to the explicit
61
+ // override; the re-exec marks itself so a bad replacement cannot loop.
62
+ const runningNode = process.env.DAVINCI_RESOLVE_ADVANCED_ASSUME_NODE || process.versions.node;
63
+ const [major, minor] = runningNode.split('.').map(Number);
53
64
  if (major < 20 || (major === 20 && minor < 9)) {
54
- process.stderr.write(
55
- `[davinci-resolve-advanced-mcp] Node ${process.versions.node} is below the ` +
65
+ const refusal = (extra = '') =>
66
+ `[davinci-resolve-advanced-mcp] Node ${runningNode} is below the ` +
56
67
  `supported floor (>=20.9). This process was started by: ${process.execPath}\n` +
57
68
  `Fix: point the MCP registration's command at a Node >=20.9 binary ` +
58
69
  `(e.g. the absolute path from \`nvm which 20\`), or update the default ` +
59
70
  `node on PATH. Re-running install.py also rewrites client configs with ` +
60
- `an absolute, version-checked node path.\n`,
61
- );
71
+ `an absolute, version-checked node path.\n${extra}`;
72
+ let picked = null;
73
+ const probed = [];
74
+ if (!process.env.DAVINCI_RESOLVE_ADVANCED_REEXEC) {
75
+ try {
76
+ // Sibling module; a managed install that only received this file falls
77
+ // through to the plain refusal instead of dying on the import.
78
+ const floor = await import(pathToFileURL(path.join(__dirname, 'node-floor.mjs')).href);
79
+ const candidates = process.env.DAVINCI_RESOLVE_ADVANCED_NO_NODE_SEARCH
80
+ ? [process.env.DAVINCI_RESOLVE_NODE].filter(Boolean)
81
+ : floor.candidateNodeBinaries();
82
+ picked = floor.findReplacementNode({ candidates, probed });
83
+ } catch (err) {
84
+ probed.push({
85
+ path: '(search unavailable)',
86
+ version: null,
87
+ error: String(err && err.message ? err.message : err),
88
+ });
89
+ }
90
+ }
91
+ if (picked) {
92
+ process.stderr.write(
93
+ `[davinci-resolve-advanced-mcp] Node ${runningNode} is below the supported floor (>=20.9); ` +
94
+ `re-executing under ${picked.path} (Node ${picked.version}). ` +
95
+ `Point the MCP registration's command at that path to skip this step.\n`,
96
+ );
97
+ const { spawnSync } = await import('node:child_process');
98
+ const child = spawnSync(picked.path, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
99
+ stdio: 'inherit',
100
+ env: {
101
+ ...process.env,
102
+ DAVINCI_RESOLVE_ADVANCED_REEXEC: '1',
103
+ DAVINCI_RESOLVE_ADVANCED_ASSUME_NODE: '',
104
+ },
105
+ });
106
+ process.exit(child.status === null ? 1 : child.status);
107
+ }
108
+ const probedLine = probed.length
109
+ ? `No usable replacement found; probed: ${probed.map((p) => `${p.path} (${p.version || (p.error ? p.error : 'not node')})`).join(', ')}\n`
110
+ : '';
111
+ process.stderr.write(refusal(probedLine));
62
112
  process.exit(1);
63
113
  }
64
114
 
115
+ // After the floor: reports the Node the server actually runs under, including
116
+ // a self-heal re-exec — the answer to "which node is the registration using?"
117
+ if (command === '--node-check') {
118
+ process.stdout.write(
119
+ `${JSON.stringify({ node: process.versions.node, execPath: process.execPath, reexec: process.env.DAVINCI_RESOLVE_ADVANCED_REEXEC === '1' })}\n`,
120
+ );
121
+ process.exit(0);
122
+ }
123
+
65
124
  const advancedRoot = path.resolve(packageRoot, 'resolve-advanced');
66
125
  const serverEntry = path.join(advancedRoot, 'server', 'index.mjs');
67
126
 
@@ -79,12 +138,12 @@ function missingRuntimePieces() {
79
138
  const manifest = JSON.parse(fs.readFileSync(path.join(advancedRoot, 'package.json'), 'utf8'));
80
139
  required = Object.keys(manifest.dependencies || {});
81
140
  } catch {
82
- return { what: `resolve-advanced/package.json (cannot tell which deps are required)` };
141
+ return {
142
+ what: `resolve-advanced/package.json (cannot tell which deps are required)`,
143
+ };
83
144
  }
84
145
  const modulesDir = path.join(advancedRoot, 'node_modules');
85
- const missing = required.filter(
86
- (dep) => !fs.existsSync(path.join(modulesDir, ...dep.split('/'))),
87
- );
146
+ const missing = required.filter((dep) => !fs.existsSync(path.join(modulesDir, ...dep.split('/'))));
88
147
  // Deps may also be hoisted above the package (an npm/npx install puts them in
89
148
  // a parent node_modules), so an empty local node_modules is not conclusive —
90
149
  // only report deps the resolver genuinely cannot see.
@@ -103,11 +162,11 @@ const gap = missingRuntimePieces();
103
162
  if (gap) {
104
163
  process.stderr.write(
105
164
  `[davinci-resolve-advanced-mcp] cannot start: ${gap.what}.\n` +
106
- `This install is at ${packageRoot}.\n` +
107
- `Fix: run \`npx davinci-resolve-mcp setup\` to repair the managed install ` +
108
- `(it syncs resolve-advanced/ and installs its Node dependencies), or run the ` +
109
- `server straight from the package with ` +
110
- `\`npx -y --package davinci-resolve-mcp davinci-resolve-advanced-mcp\`.\n`,
165
+ `This install is at ${packageRoot}.\n` +
166
+ `Fix: run \`npx davinci-resolve-mcp setup\` to repair the managed install ` +
167
+ `(it syncs resolve-advanced/ and installs its Node dependencies), or run the ` +
168
+ `server straight from the package with ` +
169
+ `\`npx -y --package davinci-resolve-mcp davinci-resolve-advanced-mcp\`.\n`,
111
170
  );
112
171
  process.exit(1);
113
172
  }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Node-floor self-heal for the advanced launcher.
3
+ *
4
+ * The advanced server needs Node >=20.9 (sharp's own floor; better-sqlite3 is
5
+ * a native module built for one ABI). The failure that keeps recurring is not
6
+ * the floor — it is the MCP registration's `command` landing on an old nvm
7
+ * binary (v18.20.8 on this machine, twice) because a GUI app rewrote the
8
+ * config or a shell whose default lags ran the installer. Refusing with the
9
+ * fix printed is right, but a launcher that can SEE a suitable Node on the
10
+ * same machine should use it: find one, re-exec under it, and say so.
11
+ *
12
+ * Pure functions here so the search and the version rule are unit-testable
13
+ * without spawning anything; the launcher wires them to real probes.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+
20
+ export const NODE_FLOOR = { major: 20, minor: 9 };
21
+
22
+ /** '20.19.0' → true, 'v18.20.8' → false, garbage → false. */
23
+ export function meetsFloor(version, floor = NODE_FLOOR) {
24
+ const m = String(version || '').match(/^v?(\d+)\.(\d+)/);
25
+ if (!m) return false;
26
+ const [major, minor] = [Number(m[1]), Number(m[2])];
27
+ return major > floor.major || (major === floor.major && minor >= floor.minor);
28
+ }
29
+
30
+ /** Numeric sort key for a version string; unparsable → [0,0,0]. */
31
+ function versionKey(v) {
32
+ const m = String(v || '').match(/^v?(\d+)\.(\d+)\.(\d+)/);
33
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
34
+ }
35
+ const byVersionDesc = (a, b) => {
36
+ const ka = versionKey(a);
37
+ const kb = versionKey(b);
38
+ for (let i = 0; i < 3; i += 1) if (ka[i] !== kb[i]) return kb[i] - ka[i];
39
+ return 0;
40
+ };
41
+
42
+ /**
43
+ * Where a Node >=20.9 might live on this machine, best first:
44
+ * 1. DAVINCI_RESOLVE_NODE (an explicit operator choice)
45
+ * 2. nvm's versions dir ($NVM_DIR or ~/.nvm), newest version first
46
+ * 3. Homebrew / system / Windows default install paths
47
+ * The current executable is excluded — it is the one that failed.
48
+ */
49
+ export function candidateNodeBinaries({
50
+ env = process.env,
51
+ home = env.HOME || env.USERPROFILE || '',
52
+ platform = process.platform,
53
+ execPath = process.execPath,
54
+ exists = fs.existsSync,
55
+ readdir = (d) => fs.readdirSync(d),
56
+ } = {}) {
57
+ const out = [];
58
+ const push = (p) => {
59
+ if (p && p !== execPath && !out.includes(p)) out.push(p);
60
+ };
61
+ // The explicit override is honored as given — even when it names the very
62
+ // binary that started us (a faked-version test, or an operator re-pointing
63
+ // the registration and forgetting to restart).
64
+ if (env.DAVINCI_RESOLVE_NODE) out.push(env.DAVINCI_RESOLVE_NODE);
65
+ const nvmDir = env.NVM_DIR || (home ? path.join(home, '.nvm') : '');
66
+ const versionsDir = nvmDir ? path.join(nvmDir, 'versions', 'node') : '';
67
+ if (versionsDir && exists(versionsDir)) {
68
+ let entries = [];
69
+ try {
70
+ entries = readdir(versionsDir);
71
+ } catch {
72
+ entries = [];
73
+ }
74
+ for (const v of [...entries].sort(byVersionDesc)) {
75
+ push(platform === 'win32' ? path.join(versionsDir, v, 'node.exe') : path.join(versionsDir, v, 'bin', 'node'));
76
+ }
77
+ }
78
+ if (platform === 'win32') {
79
+ for (const p of [
80
+ env.ProgramFiles ? path.join(env.ProgramFiles, 'nodejs', 'node.exe') : '',
81
+ env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'nodejs', 'node.exe') : '',
82
+ ])
83
+ push(p);
84
+ } else {
85
+ for (const p of ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/snap/bin/node']) push(p);
86
+ }
87
+ return out.filter((p) => exists(p));
88
+ }
89
+
90
+ /** Ask a binary for its Node version; null when it does not run or is not Node. */
91
+ export function probeNodeVersion(binary, run = spawnSync) {
92
+ try {
93
+ const r = run(binary, ['-p', 'process.versions.node'], {
94
+ encoding: 'utf8',
95
+ timeout: 5000,
96
+ });
97
+ if (r.status !== 0) return null;
98
+ const v = String(r.stdout || '').trim();
99
+ return /^\d+\.\d+\.\d+/.test(v) ? v : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * First candidate whose probed version meets the floor → {path, version}, or
107
+ * null. `probed` collects every attempt for the refusal message.
108
+ */
109
+ export function findReplacementNode({ candidates, probe = probeNodeVersion, floor = NODE_FLOOR, probed = [] } = {}) {
110
+ for (const p of candidates || []) {
111
+ const version = probe(p);
112
+ probed.push({ path: p, version });
113
+ if (version && meetsFloor(version, floor)) return { path: p, version };
114
+ }
115
+ return null;
116
+ }
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.214.1"
40
+ VERSION = "2.214.3"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.214.1",
3
+ "version": "2.214.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.214.1"
90
+ VERSION = "2.214.3"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.214.1"
14
+ VERSION = "2.214.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -29,6 +29,7 @@ from __future__ import annotations
29
29
 
30
30
  import os
31
31
  import platform
32
+ import re
32
33
  import subprocess
33
34
  from typing import Any, Dict, List, Optional
34
35
 
@@ -57,40 +58,103 @@ MACOS_RESOLVE_APPS = (
57
58
  )
58
59
 
59
60
 
60
- def _process_lines() -> Optional[List[str]]:
61
- """Every running command line, or None when that cannot be determined.
61
+ _PID_PREFIX = re.compile(r"^\s*(\d+)\s+(.*)$")
62
+
62
63
 
63
- None rather than an empty list on failure: an unanswerable question must not
64
- become "nothing is running", which is the answer that leads to launching a
65
- second instance on top of a live one.
64
+ def _run_ps(columns: str) -> Optional[List[str]]:
65
+ """`ps -Awwo <columns>` as lines, or None when it cannot be run.
66
+
67
+ `-ww` on purpose: without it BSD ps may cut long command lines to the
68
+ terminal width, and a cut line no longer ends in the executable. Not
69
+ reproduced here (the Resolve path is 70 characters), but the second-
70
+ instance guard should not depend on where a launch argument happens to
71
+ fall relative to a column limit.
66
72
  """
67
73
  try:
68
- if platform.system().lower() == "windows":
69
- # `tasklist` prints no command line, so the flag is invisible there.
70
- # WMIC does print it and is what makes headless detection possible.
71
- #
72
- # Decoded explicitly: `text=True` alone decodes with the locale
73
- # codepage, which raises UnicodeDecodeError on a byte cp1252 has no
74
- # mapping for and this read is the input to the second-instance
75
- # guard, so it must fail to "cannot tell", never to an exception.
76
- # ASCII is byte-identical under both codecs, so the matching this
77
- # feeds is unchanged; what WMIC emits for a non-ASCII install path
78
- # on a non-English Windows is not something we can verify here.
74
+ out = subprocess.run(
75
+ ["ps", "-Awwo", columns], capture_output=True, text=True,
76
+ encoding="utf-8", errors="replace", timeout=10, check=False,
77
+ )
78
+ except Exception: # pragma: no cover - defensive; an unknown answer is None
79
+ return None
80
+ if out.returncode != 0 and not out.stdout:
81
+ return None
82
+ return (out.stdout or "").splitlines()
83
+
84
+
85
+ def _split_pid(line: str, index: int):
86
+ """(pid, field) for a `pid=,<col>=` row; a row with no pid gets a synthetic one.
87
+
88
+ The synthetic pid is the row index, so two column listings of the same
89
+ length join row-by-row. That is what keeps a fake process table written as
90
+ bare command lines (the shape every existing test uses) meaningful: it is
91
+ read as both the executable column and the argv column of one process.
92
+ """
93
+ match = _PID_PREFIX.match(line)
94
+ if match:
95
+ return int(match.group(1)), match.group(2)
96
+ return -(index + 1), line
97
+
98
+
99
+ def _process_table() -> Optional[List[Dict[str, Optional[str]]]]:
100
+ """One row per process: `{pid, comm, args}`, or None when undeterminable.
101
+
102
+ Two columns because they fail independently. `args` is the argument
103
+ vector, the only place `-nogui` is visible — but the kernel refuses to
104
+ expose it for some processes (ps prints `(Resolve)` in parentheses) and a
105
+ launch argument after the path breaks a suffix match on it. `comm` is the
106
+ executable path as the kernel knows it — on macOS the full path — and it
107
+ is readable whenever the process is. An instance is counted on EITHER;
108
+ the mode is read from argv when argv is readable.
109
+
110
+ None rather than an empty list on failure: an unanswerable question must
111
+ not become "nothing is running", which is the answer that leads to
112
+ launching a second instance on top of a live one.
113
+ """
114
+ if platform.system().lower() == "windows":
115
+ # `tasklist` prints no command line, so the flag is invisible there.
116
+ # WMIC does print it and is what makes headless detection possible.
117
+ #
118
+ # Decoded explicitly: `text=True` alone decodes with the locale
119
+ # codepage, which raises UnicodeDecodeError on a byte cp1252 has no
120
+ # mapping for — and this read is the input to the second-instance
121
+ # guard, so it must fail to "cannot tell", never to an exception.
122
+ # ASCII is byte-identical under both codecs, so the matching this
123
+ # feeds is unchanged; what WMIC emits for a non-ASCII install path
124
+ # on a non-English Windows is not something we can verify here.
125
+ try:
79
126
  out = subprocess.run(
80
127
  ["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
81
128
  capture_output=True, text=True, encoding="utf-8", errors="replace",
82
129
  timeout=10, check=False,
83
130
  )
84
- else:
85
- out = subprocess.run(
86
- ["ps", "-Ao", "command="], capture_output=True, text=True,
87
- encoding="utf-8", errors="replace", timeout=10, check=False,
88
- )
131
+ except Exception: # pragma: no cover - defensive; an unknown answer is None
132
+ return None
89
133
  if out.returncode != 0 and not out.stdout:
90
134
  return None
91
- return (out.stdout or "").splitlines()
92
- except Exception: # pragma: no cover - defensive; an unknown answer is None
135
+ return [{"pid": -(i + 1), "comm": None, "args": line}
136
+ for i, line in enumerate((out.stdout or "").splitlines())]
137
+
138
+ comm_lines = _run_ps("pid=,comm=")
139
+ args_lines = _run_ps("pid=,args=")
140
+ if comm_lines is None and args_lines is None:
93
141
  return None
142
+ rows: Dict[int, Dict[str, Optional[str]]] = {}
143
+ for index, line in enumerate(comm_lines or []):
144
+ pid, comm = _split_pid(line, index)
145
+ rows.setdefault(pid, {"pid": pid, "comm": None, "args": None})["comm"] = comm
146
+ for index, line in enumerate(args_lines or []):
147
+ pid, args = _split_pid(line, index)
148
+ rows.setdefault(pid, {"pid": pid, "comm": None, "args": None})["args"] = args
149
+ return list(rows.values())
150
+
151
+
152
+ def _process_lines() -> Optional[List[str]]:
153
+ """Argument vectors of every process, kept for callers that read only argv."""
154
+ table = _process_table()
155
+ if table is None:
156
+ return None
157
+ return [row["args"] for row in table if row["args"] is not None]
94
158
 
95
159
 
96
160
  def _matches_pattern(executable: str) -> bool:
@@ -137,6 +201,22 @@ def _executable_from_line(line: str) -> str:
137
201
  close = text.find('"', 1)
138
202
  if close > 1:
139
203
  return text[1:close]
204
+ # A launch ARGUMENT after the path — a project file, most likely — is not a
205
+ # flag, so the flag-stripping loop below leaves it attached and the suffix
206
+ # test fails. If the line STARTS with a path that ends in a Resolve pattern
207
+ # at a token boundary, that path is the executable, whatever follows it.
208
+ # The prefix must contain no quote (a launcher quoting the path) and no
209
+ # flag token (`/bin/sh -c /opt/resolve/bin/resolve` names Resolve without
210
+ # being it), which keeps the "mere mention" cases out.
211
+ for pattern in RESOLVE_PROCESS_PATTERNS:
212
+ cut = text.find(pattern)
213
+ while cut != -1:
214
+ end = cut + len(pattern)
215
+ prefix = text[:end]
216
+ at_boundary = end == len(text) or text[end].isspace()
217
+ if at_boundary and '"' not in prefix and " -" not in prefix:
218
+ return prefix
219
+ cut = text.find(pattern, cut + 1)
140
220
  while True:
141
221
  stripped = text.rstrip()
142
222
  cut = stripped.rfind(" -")
@@ -151,12 +231,42 @@ def _executable_from_line(line: str) -> str:
151
231
  return text
152
232
 
153
233
 
234
+ def _argv_unreadable(args: Optional[str]) -> bool:
235
+ """ps prints `(name)` when the kernel will not hand over the argument vector."""
236
+ if args is None:
237
+ return True
238
+ text = args.strip()
239
+ return text.startswith("(") and text.endswith(")")
240
+
241
+
242
+ def _resolve_rows() -> Optional[List[Dict[str, Optional[str]]]]:
243
+ """Process-table rows that are a running Resolve application."""
244
+ table = _process_table()
245
+ if table is None:
246
+ return None
247
+ matched = []
248
+ for row in table:
249
+ args = row.get("args")
250
+ comm = row.get("comm")
251
+ by_args = args is not None and not _argv_unreadable(args) and _is_resolve_command(args)
252
+ by_comm = comm is not None and _matches_pattern(comm.strip())
253
+ if by_args or by_comm:
254
+ matched.append(row)
255
+ return matched
256
+
257
+
154
258
  def resolve_processes() -> Optional[List[str]]:
155
- """Command lines of running Resolve applications, or None if undeterminable."""
156
- lines = _process_lines()
157
- if lines is None:
259
+ """Command lines of running Resolve applications, or None if undeterminable.
260
+
261
+ A row whose argv is unreadable reports its executable path instead, so a
262
+ caller still sees WHICH Resolve is up even when it cannot see how it was
263
+ started.
264
+ """
265
+ rows = _resolve_rows()
266
+ if rows is None:
158
267
  return None
159
- return [line for line in lines if _is_resolve_command(line)]
268
+ return [row["args"] if not _argv_unreadable(row.get("args")) else (row.get("comm") or "")
269
+ for row in rows]
160
270
 
161
271
 
162
272
  #: Where the scripting library sits relative to the Resolve executable. The
@@ -212,8 +322,8 @@ def runtime_mode() -> Dict[str, Any]:
212
322
  the process list unavailable. Callers must not read None as False; a wrong
213
323
  "it has a UI" is what makes an agent wait for a dialog that will never open.
214
324
  """
215
- processes = resolve_processes()
216
- if processes is None:
325
+ rows = _resolve_rows()
326
+ if rows is None:
217
327
  return {
218
328
  "determinable": False,
219
329
  "running": None,
@@ -221,7 +331,7 @@ def runtime_mode() -> Dict[str, Any]:
221
331
  "instances": None,
222
332
  "command_lines": [],
223
333
  }
224
- if not processes:
334
+ if not rows:
225
335
  return {
226
336
  "determinable": True,
227
337
  "running": False,
@@ -229,15 +339,23 @@ def runtime_mode() -> Dict[str, Any]:
229
339
  "instances": 0,
230
340
  "command_lines": [],
231
341
  }
342
+ readable = [row["args"] for row in rows if not _argv_unreadable(row.get("args"))]
343
+ # Any headless instance makes the reachable one headless: only one Resolve
344
+ # can hold the singleton, so a second is a conflict to report rather than
345
+ # a mode to average. An instance counted on its executable path alone has
346
+ # an argv this process cannot read, so unless another instance shows the
347
+ # flag the mode is UNKNOWN — None, never False: a wrong "it has a UI" is
348
+ # what makes an agent wait for a dialog that will never open.
349
+ headless: Optional[bool] = any(HEADLESS_FLAG in line for line in readable)
350
+ if not headless and len(readable) < len(rows):
351
+ headless = None
232
352
  return {
233
353
  "determinable": True,
234
354
  "running": True,
235
- # Any headless instance makes the reachable one headless: only one
236
- # Resolve can hold the singleton, so a second is a conflict to report
237
- # rather than a mode to average.
238
- "headless": any(HEADLESS_FLAG in line for line in processes),
239
- "instances": len(processes),
240
- "command_lines": processes,
355
+ "headless": headless,
356
+ "instances": len(rows),
357
+ "command_lines": [row["args"] if not _argv_unreadable(row.get("args"))
358
+ else (row.get("comm") or "") for row in rows],
241
359
  }
242
360
 
243
361