davinci-resolve-mcp 2.218.0 → 2.218.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/CHANGELOG.md CHANGED
@@ -2,6 +2,104 @@
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.218.2 — the AddTransition null-duration boundary, measured
6
+
7
+ Measured by @legionsound on Studio 21.1.0.14 (#209), recorded here; no behavior
8
+ changed.
9
+
10
+ ### Documentation
11
+
12
+ - **An explicit `"duration": null` is not a special case.** v2.218.0 shipped
13
+ `add_transition` forwarding an explicit null verbatim rather than dropping
14
+ the key, on the principle that inventing a default would misreport what the
15
+ server asked Resolve for — but only the 24-frame case had been measured, so
16
+ whether Resolve read a null as "automatic" or refused it was an open
17
+ question raised on #209. It is now answered: on fresh timelines with the same
18
+ handled red/blue fixture, **omitting the key and passing `duration: null`
19
+ behaved identically**, each creating a transition of 8 frames spanning 67–75
20
+ around a cut at 71.
21
+ - So the shipped behavior is correct as written, and the ledger now says why a
22
+ future wrapper must **not** strip an explicit null to route around a
23
+ refusal — there is no refusal to route around. `resolve_control api_truth
24
+ "AddTransition"` carries this, alongside the standing 21.1 gap it does not
25
+ close: there is still no accessor for an existing transition's type,
26
+ alignment or duration beyond its name and frame range, and no clone verb.
27
+ - The 8 frames is recorded as **what that build chose for that fixture, not a
28
+ documented default**, and these two cases were creation and readback only —
29
+ they were not rendered, unlike the 24-frame fixture behind v2.218.0.
30
+
31
+ ### Validation
32
+
33
+ - Ledger and generated `docs/reference/api-limitations.md` regenerated; full
34
+ suite green. No code path changed, so no live Resolve run was required here —
35
+ and this machine is Studio 19.1.3.7, below the 21.1 floor, where the method
36
+ refuses by design. The measurement is @legionsound's on 21.1.0.14.
37
+
38
+ ## What's New in v2.218.1 — Windows 11 process detection survives the removal of WMIC
39
+
40
+ Reported by @Nikibakht (#210), verified on Windows 11 Pro build 26200.
41
+
42
+ ### Fixed
43
+
44
+ - **Every tool refused with `RESOLVE_NOT_RUNNING` on Windows 11 build 26200+,
45
+ while Resolve was running in front of the user.** Process detection read the
46
+ running Resolve's command line through `wmic`, which **Microsoft removed in
47
+ build 26200** — it is neither on `PATH` nor at `C:\Windows\System32\wbem`.
48
+ Spawning it raised `FileNotFoundError`, the read returned `None`, and `None`
49
+ correctly means "cannot determine whether Resolve is running", so the server
50
+ refused to act and declined to launch. The detection logic was right; the
51
+ reader it depended on had ceased to exist.
52
+ - Windows now tries a chain of readers — `wmic`, then Windows PowerShell's
53
+ `Get-CimInstance Win32_Process`, then `pwsh` — and uses the first that
54
+ answers. `None` is returned only when **no** reader ran; a reader that ran
55
+ and found nothing still returns an empty list, which is a different answer.
56
+ Machines that still have WMIC are unaffected, and keeping it first costs
57
+ nothing, because a missing binary fails instantly rather than burning the
58
+ ten-second timeout.
59
+
60
+ ### Changed
61
+
62
+ - The PowerShell reader returns **`ProcessId`, `Name`, `ExecutablePath` and
63
+ `CommandLine`**, not the command line alone, so Windows now fills the same
64
+ two-column process table as macOS and Linux. The columns fail independently,
65
+ and @Nikibakht measured how: querying as an unelevated user on build 26200, a
66
+ process the caller cannot fully read still returns its row with `ProcessId`
67
+ and `Name` populated and `CommandLine` NULL — the *column* is
68
+ access-restricted, not the row. Reading only the command line would turn
69
+ such an instance into no row at all: an empty list, which does not mean
70
+ "cannot tell", it means "nothing is running", and that is the answer that
71
+ launches a second Resolve on top of a live one.
72
+ - `Name` is in that query because of the same measurement. It showed `Name`
73
+ surviving the access restriction; it did **not** show `ExecutablePath`
74
+ surviving it, and for a protected process that field is commonly empty too,
75
+ so the executable column falls back to the bare process name — which the
76
+ existing match patterns already accept. An instance is counted on either
77
+ column, and the mode is reported as unknown rather than guessed when the
78
+ argument vector is unreadable, since `-nogui` is only ever visible there.
79
+ Windows rows also carry real pids instead of the synthetic negative ones the
80
+ WMIC branch invents.
81
+
82
+ ### Validation
83
+
84
+ - Full suite green: 3,446 passed, 1 skipped. Ten new tests cover the reader chain: a machine with no WMIC, `-nogui`
85
+ surviving the new reader, an unreadable command line still counting as an
86
+ instance, a row where only the process name survives, WMIC still winning
87
+ where it exists, an empty answer ending the chain rather than falling
88
+ through, a broken reader falling through, no reader at all staying
89
+ undeterminable, and the two parsing edges (a command line containing tabs, a
90
+ non-numeric pid).
91
+ - **Not verified on Windows hardware by this project — there is none here.**
92
+ The WMIC absence, the `FileNotFoundError` it raises inside the server's own
93
+ venv, and the access-restricted row shape were all measured by @Nikibakht on
94
+ Windows 11 Pro build 26200. The local half is the unit coverage above, run
95
+ against a faked process spawn.
96
+ - One thing remains **untested by anyone**: an actual Resolve running elevated
97
+ or under a different Windows account. The reporter runs it as the same
98
+ unelevated user and said so rather than guessing; the access-restricted row
99
+ shape above is a proxy measured on other processes in that same access
100
+ class. The fallback is written so that it costs nothing if that case never
101
+ arises.
102
+
5
103
  ## What's New in v2.218.0 — native Resolve 21.1 transition creation
6
104
 
7
105
  Contributed by @legionsound (#209), live-validated on Studio 21.1.0.14.
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.218.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.218.2-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(368%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.218.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.218.2-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(368%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.218.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.218.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -82,7 +82,7 @@ equivalent, blocking full automation.
82
82
  ### Transition create / copy / clone
83
83
 
84
84
  - **Object:** `Timeline / TimelineItem`
85
- - **Behavior:** CREATION IS FIXED IN 21.1, READBACK IS NOT. Reported by @billcarroll (PR #197) from an attribute probe on Studio 21.1.0.14 (2026-09-08; not reproduced here, no 21.1 install): TimelineItem.AddTransition resolves to a <BlackmagicFusion.PyFunctionCall object>, not None. Its stub signature is AddTransition(transitionOptions) -> TimelineItem | None, where transitionOptions carries type (e.g. 'Cross Dissolve'), category ('simple'|'fusion'|'ofx'|'audio'), position ('start'|'end'), alignment ('left'|'center'|'right') and an optional duration in frames. That original probe did not invoke the method. UPDATE, contributor-validated by @legionsound on Studio 21.1.0.14, macOS, 2026-09-09 (not reproduced by the maintainer on 19.1.3.7): a synthetic red/blue pair with handles accepted a 24-frame centered Cross Dissolve. GetStart/End returned 59/83 around cut 71, GetDuration returned 24, and source clip spans were unchanged. Both community interfaces rendered identical 142-frame movies with a progressive red-to-blue blend. With zero handles the native call returned None. This validates that fixture, not other effects, audio transitions or alignments. See resolve211-native-transitions.md. WHAT REMAINS MISSING ON 21.1: reading a transition back. There is still no accessor for an existing transition's type, alignment or duration beyond its name string and frame range, and no clone verb — alignment and duration are write-only arguments to AddTransition. The pre-21.1 statement, kept as the historical record: there was no method to ADD or CLONE an edit transition — no AddTransition/CreateTransition/AddVideoTransition on Timeline or TimelineItem (dir(), 21.0.4.5). CORRECTION, measured on Studio 21.0.4.5 (2026-08-12): this entry previously said transitions applied in the UI are 'invisible to and unmodifiable by scripts'. BOTH HALVES WERE WRONG and are withdrawn. A transition IS a first-class timeline item: a 12-frame Cross Dissolve applied through the Edit-page right-click menu enumerates in GetItemListInTrack('video', 1) as GetName()=='Cross Dissolve', GetStart()==86426, GetDuration()==12 — centered on a cut at 86432 — with a stable GetUniqueId() and a working GetTrackTypeAndIndex(). A transition authored offline into a .drp and imported reads IDENTICALLY, so the route that created it does not matter. It is also REMOVABLE: Timeline.DeleteClips([transition], False) returns True and deletes it, leaving both adjacent clips at their original starts and durations. THE DISCRIMINATOR between a transition item and a clip item is GetProperty(): a transition returns an EMPTY dict where a video clip returns 26 transform keys; it also has no MediaPoolItem and no Fusion comp. WHAT IS GENUINELY MISSING (pre-21.1: creation too; on 21.1+ read the paragraph above): cloning, and any type/alignment/parameter detail — the transition's kind is knowable ONLY from its name string, and there is no way to read its alignment (centered/start/end) or edit its duration. AUDIO NUANCE (measured 2026-09-01 on 19.1.3.7, E113): an audio cross-fade enumerates in GetItemListInTrack('audio', n) with an EMPTY GetName() (24 frames, centered on the cut, between the two clips) — so on audio lanes even the kind is not readable from the name. The discriminator that holds for BOTH: GetMediaPoolItem() is None AND GetProperty() is empty — BUT a Solid Color generator AND a subtitle item read the same way (GetProperty() None, no MediaPoolItem; measured E115), so that pair only separates clips from non-clips. What separates a transition from a generator is GEOMETRY: a transition straddles a cut (one neighbour ends inside its span, another starts inside it) while a generator owns its span. timeline.get_items reports `kind` on that basis.
85
+ - **Behavior:** CREATION IS FIXED IN 21.1, READBACK IS NOT. Reported by @billcarroll (PR #197) from an attribute probe on Studio 21.1.0.14 (2026-09-08; not reproduced here, no 21.1 install): TimelineItem.AddTransition resolves to a <BlackmagicFusion.PyFunctionCall object>, not None. Its stub signature is AddTransition(transitionOptions) -> TimelineItem | None, where transitionOptions carries type (e.g. 'Cross Dissolve'), category ('simple'|'fusion'|'ofx'|'audio'), position ('start'|'end'), alignment ('left'|'center'|'right') and an optional duration in frames. That original probe did not invoke the method. UPDATE, contributor-validated by @legionsound on Studio 21.1.0.14, macOS, 2026-09-09 (not reproduced by the maintainer on 19.1.3.7): a synthetic red/blue pair with handles accepted a 24-frame centered Cross Dissolve. GetStart/End returned 59/83 around cut 71, GetDuration returned 24, and source clip spans were unchanged. Both community interfaces rendered identical 142-frame movies with a progressive red-to-blue blend. With zero handles the native call returned None. This validates that fixture, not other effects, audio transitions or alignments. See resolve211-native-transitions.md. DURATION IS OPTIONAL AND AN EXPLICIT NULL IS NOT A SPECIAL CASE, measured by @legionsound on Studio 21.1.0.14 (2026-09-09) on fresh timelines with the same handled red/blue fixture: omitting the duration key and passing duration=None behaved IDENTICALLY, both creating a transition at GetDuration()==8 spanning 67-75 around a cut at 71. So a wrapper must NOT strip an explicit null to work around a refusal — there is no refusal to work around. The 8 frames is what that build chose for that fixture, NOT a documented default; this was creation and readback only, not rendered. WHAT REMAINS MISSING ON 21.1: reading a transition back. There is still no accessor for an existing transition's type, alignment or duration beyond its name string and frame range, and no clone verb — alignment and duration are write-only arguments to AddTransition. The pre-21.1 statement, kept as the historical record: there was no method to ADD or CLONE an edit transition — no AddTransition/CreateTransition/AddVideoTransition on Timeline or TimelineItem (dir(), 21.0.4.5). CORRECTION, measured on Studio 21.0.4.5 (2026-08-12): this entry previously said transitions applied in the UI are 'invisible to and unmodifiable by scripts'. BOTH HALVES WERE WRONG and are withdrawn. A transition IS a first-class timeline item: a 12-frame Cross Dissolve applied through the Edit-page right-click menu enumerates in GetItemListInTrack('video', 1) as GetName()=='Cross Dissolve', GetStart()==86426, GetDuration()==12 — centered on a cut at 86432 — with a stable GetUniqueId() and a working GetTrackTypeAndIndex(). A transition authored offline into a .drp and imported reads IDENTICALLY, so the route that created it does not matter. It is also REMOVABLE: Timeline.DeleteClips([transition], False) returns True and deletes it, leaving both adjacent clips at their original starts and durations. THE DISCRIMINATOR between a transition item and a clip item is GetProperty(): a transition returns an EMPTY dict where a video clip returns 26 transform keys; it also has no MediaPoolItem and no Fusion comp. WHAT IS GENUINELY MISSING (pre-21.1: creation too; on 21.1+ read the paragraph above): cloning, and any type/alignment/parameter detail — the transition's kind is knowable ONLY from its name string, and there is no way to read its alignment (centered/start/end) or edit its duration. AUDIO NUANCE (measured 2026-09-01 on 19.1.3.7, E113): an audio cross-fade enumerates in GetItemListInTrack('audio', n) with an EMPTY GetName() (24 frames, centered on the cut, between the two clips) — so on audio lanes even the kind is not readable from the name. The discriminator that holds for BOTH: GetMediaPoolItem() is None AND GetProperty() is empty — BUT a Solid Color generator AND a subtitle item read the same way (GetProperty() None, no MediaPoolItem; measured E115), so that pair only separates clips from non-clips. What separates a transition from a generator is GEOMETRY: a transition straddles a cut (one neighbour ends inside its span, another starts inside it) while a generator owns its span. timeline.get_items reports `kind` on that basis.
86
86
  - **Workaround / current handling:** Automated QC of existing transitions IS possible and is the main practical need — enumerate GetItemListInTrack, treat any item whose GetProperty() is empty and whose GetMediaPoolItem() is None as a transition, and read its name, start and duration. Removal is scriptable via Timeline.DeleteClips. To CREATE one, either apply it in the Resolve UI, or author it offline and import: the advanced server's drp place_transition writes a cross dissolve at an abutting cut ({track, atFrame, durationFrames}) and it round-trips into Resolve 21.0.4.5 reading back at the expected centered range. On 21.1+ prefer TimelineItem.AddTransition, which takes the type, category, edge, alignment and duration directly.
87
87
  - **Tags:** missing-method, timeline, transition
88
88
 
@@ -45,8 +45,17 @@ The included scratch test exercises both community interfaces and creates render
45
45
  jobs for independent movie inspection. Unit tests cover actual returned spans,
46
46
  missing native methods, None/False failures, malformed options, optional duration,
47
47
  write classification, and dry-run refusal. Audio transitions, Fusion/OFX effects,
48
- other alignments, automatic duration and repeated insertion are not live-validated
49
- by this contribution. Support for their documented options is pass-through.
48
+ other alignments and repeated insertion are not live-validated by this
49
+ contribution. Support for their documented options is pass-through.
50
+
51
+ Automatic duration was measured afterwards, by @legionsound on Studio 21.1.0.14
52
+ (2026-09-09), on fresh timelines with the same handled red/blue fixture: omitting
53
+ the `duration` key and passing `duration: null` behaved identically, each creating
54
+ a transition of 8 frames spanning 67-75 around a cut at 71. An explicit null is
55
+ therefore not a special case, and a wrapper must not strip it to route around a
56
+ refusal — there is no refusal. The 8 frames is what that build chose for that
57
+ fixture, not a documented default, and these two cases were creation and readback
58
+ only; they were not rendered.
50
59
 
51
60
  `python tests/live_resolve211_transitions.py OUTPUT_DIR` requires a disposable
52
61
  project named `Codex Native Transition Validation 20260909`, set to 640x360/24 fps,
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.218.0"
40
+ VERSION = "2.218.2"
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.218.0",
3
+ "version": "2.218.2",
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.218.0"
90
+ VERSION = "2.218.2"
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 368-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.218.0"
14
+ VERSION = "2.218.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -915,6 +915,16 @@ API_TRUTH: List[Dict[str, Any]] = [
915
915
  "red-to-blue blend. With zero handles the native call returned "
916
916
  "None. This validates that fixture, not other effects, audio "
917
917
  "transitions or alignments. See resolve211-native-transitions.md. "
918
+ "DURATION IS OPTIONAL AND AN EXPLICIT NULL IS NOT A SPECIAL CASE, "
919
+ "measured by @legionsound on Studio 21.1.0.14 (2026-09-09) on "
920
+ "fresh timelines with the same handled red/blue fixture: omitting "
921
+ "the duration key and passing duration=None behaved IDENTICALLY, "
922
+ "both creating a transition at GetDuration()==8 spanning 67-75 "
923
+ "around a cut at 71. So a wrapper must NOT strip an explicit null "
924
+ "to work around a refusal — there is no refusal to work around. "
925
+ "The 8 frames is what that build chose for that fixture, NOT a "
926
+ "documented default; this was creation and readback only, not "
927
+ "rendered. "
918
928
  "WHAT REMAINS MISSING ON 21.1: reading a "
919
929
  "transition back. There is still no accessor for an existing "
920
930
  "transition's type, alignment or duration beyond its name string "
@@ -96,6 +96,91 @@ def _split_pid(line: str, index: int):
96
96
  return -(index + 1), line
97
97
 
98
98
 
99
+ def _windows_wmic_rows(stdout: str) -> List[Dict[str, Optional[str]]]:
100
+ """WMIC prints one command line per row, under a `CommandLine` header.
101
+
102
+ The header row and WMIC's blank padding rows are left in rather than
103
+ filtered: they are not Resolve command lines, so the executable match
104
+ drops them, and a second filter here would be a second place for that
105
+ decision to drift. There is no pid column to read, so pids are synthetic
106
+ and negative — they exist only to key the row, never to name a process.
107
+ """
108
+ return [{"pid": -(index + 1), "comm": None, "args": line}
109
+ for index, line in enumerate(stdout.splitlines())]
110
+
111
+
112
+ def _windows_cim_rows(stdout: str) -> List[Dict[str, Optional[str]]]:
113
+ """`ProcessId`, `ExecutablePath` and `CommandLine`, tab-separated per row.
114
+
115
+ Four columns rather than the command line alone, because they fail
116
+ independently exactly as they do on POSIX. Measured on build 26200 by the
117
+ reporter of #210, querying as an unelevated user: for a process the caller
118
+ cannot fully read, CIM still returns the row with `ProcessId` and `Name`
119
+ populated and `CommandLine` NULL — the *column* is access-restricted, not
120
+ the row. Reading only the command line would turn such an instance into no
121
+ row at all: an empty list, which does not mean "undeterminable", it means
122
+ "nothing is running", and that is the answer that launches a second
123
+ Resolve on top of a live one.
124
+
125
+ `Name` rather than `ExecutablePath` alone is the reason this holds. That
126
+ measurement showed `Name` surviving the access restriction; it did not
127
+ show `ExecutablePath` surviving it, and for a protected process that field
128
+ is commonly empty too. So the executable column falls back to the bare
129
+ process name, which `RESOLVE_PROCESS_PATTERNS` already matches — enough to
130
+ prove an instance is up, while the mode stays honestly unknown, since
131
+ `-nogui` is only ever visible in the command line.
132
+
133
+ Split at most three times: a command line may itself contain tabs, and it
134
+ is the last field, so everything after the third separator belongs to it.
135
+ """
136
+ rows: List[Dict[str, Optional[str]]] = []
137
+ for index, line in enumerate(stdout.splitlines()):
138
+ if not line.strip():
139
+ continue
140
+ fields = line.split("\t", 3)
141
+ fields += [""] * (4 - len(fields))
142
+ try:
143
+ pid = int(fields[0].strip())
144
+ except ValueError:
145
+ pid = -(index + 1)
146
+ rows.append({"pid": pid,
147
+ "comm": fields[2].strip() or fields[1].strip() or None,
148
+ "args": fields[3].strip() or None})
149
+ return rows
150
+
151
+
152
+ #: PowerShell equivalent of the WMIC query, emitting the four columns above.
153
+ #: The output encoding is forced because the default console codepage mangles
154
+ #: a non-ASCII install path before Python ever sees it.
155
+ _CIM_COMMAND = (
156
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8; "
157
+ "Get-CimInstance Win32_Process -Filter \"name='Resolve.exe'\" | "
158
+ "ForEach-Object { \"$($_.ProcessId)`t$($_.Name)`t$($_.ExecutablePath)`t$($_.CommandLine)\" }"
159
+ )
160
+
161
+ #: Readers for the Windows process table, tried in order until one answers.
162
+ #:
163
+ #: WMIC first, so a machine that still has it behaves exactly as it did before
164
+ #: — but **WMIC was removed in Windows 11 build 26200** and is neither on PATH
165
+ #: nor at its old System32\wbem location, so on current Windows it raises
166
+ #: FileNotFoundError and every tool refused with "Resolve is not running"
167
+ #: while Resolve sat in front of the user (#210). Keeping the old reader first
168
+ #: costs nothing precisely because absence fails instantly rather than burning
169
+ #: the timeout. Windows PowerShell 5.1 ships with Windows; `pwsh` is the
170
+ #: cross-platform 7.x binary, tried last for a machine that has only that one.
171
+ #:
172
+ #: `None` is returned only when NO reader ran. A reader that ran and found
173
+ #: nothing returns an empty list, which is a different answer.
174
+ WINDOWS_PROCESS_READERS = (
175
+ (["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
176
+ _windows_wmic_rows),
177
+ (["powershell", "-NoProfile", "-NonInteractive", "-Command", _CIM_COMMAND],
178
+ _windows_cim_rows),
179
+ (["pwsh", "-NoProfile", "-NonInteractive", "-Command", _CIM_COMMAND],
180
+ _windows_cim_rows),
181
+ )
182
+
183
+
99
184
  def _process_table() -> Optional[List[Dict[str, Optional[str]]]]:
100
185
  """One row per process: `{pid, comm, args}`, or None when undeterminable.
101
186
 
@@ -113,27 +198,28 @@ def _process_table() -> Optional[List[Dict[str, Optional[str]]]]:
113
198
  """
114
199
  if platform.system().lower() == "windows":
115
200
  # `tasklist` prints no command line, so the flag is invisible there.
116
- # WMIC does print it and is what makes headless detection possible.
201
+ # The readers below do print it, which is what makes headless
202
+ # detection possible on Windows at all.
117
203
  #
118
204
  # Decoded explicitly: `text=True` alone decodes with the locale
119
205
  # codepage, which raises UnicodeDecodeError on a byte cp1252 has no
120
206
  # mapping for — and this read is the input to the second-instance
121
207
  # guard, so it must fail to "cannot tell", never to an exception.
122
208
  # 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:
126
- out = subprocess.run(
127
- ["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
128
- capture_output=True, text=True, encoding="utf-8", errors="replace",
129
- timeout=10, check=False,
130
- )
131
- except Exception: # pragma: no cover - defensive; an unknown answer is None
132
- return None
133
- if out.returncode != 0 and not out.stdout:
134
- return None
135
- return [{"pid": -(i + 1), "comm": None, "args": line}
136
- for i, line in enumerate((out.stdout or "").splitlines())]
209
+ # feeds is unchanged; what these readers emit for a non-ASCII install
210
+ # path on a non-English Windows is not something we can verify here.
211
+ for reader, parse in WINDOWS_PROCESS_READERS:
212
+ try:
213
+ out = subprocess.run(
214
+ reader, capture_output=True, text=True, encoding="utf-8",
215
+ errors="replace", timeout=10, check=False,
216
+ )
217
+ except Exception:
218
+ continue # this reader is unusable here; try the next one
219
+ if out.returncode != 0 and not (out.stdout or "").strip():
220
+ continue
221
+ return parse(out.stdout or "")
222
+ return None
137
223
 
138
224
  comm_lines = _run_ps("pid=,comm=")
139
225
  args_lines = _run_ps("pid=,args=")