@indigoai-us/hq-cli 5.341.2 → 5.341.4

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/commands/billing-sponsor.js +20 -1
  4. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  5. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  6. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  7. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  8. package/dist/commands/doctor.js +2 -2
  9. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  10. package/dist/lib/billing/plan-lock.js +19 -2
  11. package/dist/lib/bot/owner-context.d.ts +2 -0
  12. package/dist/lib/bot/owner-context.js +19 -1
  13. package/dist/lib/daemon/flag-monitor.d.ts +5 -0
  14. package/dist/lib/daemon/flag-monitor.js +14 -1
  15. package/dist/lib/daemon/protocol.d.ts +26 -3
  16. package/dist/lib/daemon/protocol.js +9 -5
  17. package/dist/lib/daemon/remote-control.d.ts +7 -0
  18. package/dist/lib/daemon/remote-control.js +135 -2
  19. package/dist/lib/daemon/run.js +14 -16
  20. package/dist/lib/daemon/server.d.ts +13 -0
  21. package/dist/lib/daemon/server.js +283 -28
  22. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  23. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  24. package/dist/lib/diagnostics.d.ts +9 -0
  25. package/dist/lib/diagnostics.js +12 -0
  26. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  27. package/dist/lib/doctor/checks/anywhere.js +131 -13
  28. package/dist/lib/doctor/json-output.d.ts +2 -0
  29. package/dist/lib/doctor/json-output.js +1 -0
  30. package/dist/lib/doctor/registry.js +1 -3
  31. package/dist/lib/doctor/types.d.ts +4 -0
  32. package/dist/utils/run-bundled-script.d.ts +2 -0
  33. package/dist/utils/run-bundled-script.js +10 -1
  34. package/docs/anywhere-runtime.md +32 -5
  35. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.341.4] — 2026-10-03
6
+ - Prevent daemon restarts from waiting on stale or already-owned Claude bridge sessions, and keep captured startup diagnostics useful without exposing authorization credentials.
7
+
8
+ ## [5.341.3] — 2026-10-03
9
+ - Preserve policy-trigger parity for Windows PATH resolution, filesystem paths, and frontmatter parsing.
10
+ - Report unexpected company-resolve, plan-limit body and late owner-context failures that a bare catch used to hide. The report prints only with `--verbose` or `HQ_DEBUG` set.
11
+ - Enable hqd on Windows through a bundled PowerShell named-pipe host that applies and verifies a current-user-only DACL, while refusing startup if the security check fails.
12
+ - `hq doctor` now reports hqd reachability, runtime flag freshness and fallback
13
+ reasons for the current directory. The same fields are available with
14
+ `hq doctor --json`, including while the runtime flag is off.
15
+ - Hardened Unix hqd socket startup to reject symlinks anywhere in the socket directory path and unexpected socket-path entries, replace only a stale socket owned by the current user, and verify user-only directory and socket permissions. The default socket now lives under the private `~/.hq/daemon/` directory.
16
+
5
17
  ## [5.341.2] — 2026-10-03
6
18
 
7
19
  - Preserve the original integrations response read error instead of returning a consumed response.
@@ -0,0 +1,282 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
3
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ try {
5
+ Add-Type -AssemblyName System.IO.Pipes.AccessControl -ErrorAction SilentlyContinue
6
+ } catch {
7
+ # Windows PowerShell 5.1 ships the access-control types in its framework assemblies.
8
+ }
9
+
10
+ $source = @'
11
+ using System;
12
+ using System.Collections;
13
+ using System.Collections.Concurrent;
14
+ using System.IO;
15
+ using System.IO.Pipes;
16
+ using System.Linq;
17
+ using System.Reflection;
18
+ using System.Security.AccessControl;
19
+ using System.Security.Principal;
20
+ using System.Text;
21
+ using System.Threading;
22
+ using System.Threading.Tasks;
23
+ using Microsoft.Win32.SafeHandles;
24
+ using System.Runtime.InteropServices;
25
+
26
+ public static class HqdWindowsPipeHost
27
+ {
28
+ private static readonly object OutputLock = new object();
29
+ private static readonly ConcurrentDictionary<int, NamedPipeServerStream> Connections = new ConcurrentDictionary<int, NamedPipeServerStream>();
30
+ private static int NextConnectionId;
31
+ private static volatile bool Stopping;
32
+ private static readonly ManualResetEventSlim AcceptStarted = new ManualResetEventSlim(false);
33
+ private static string PipeName;
34
+ private static SecurityIdentifier UserSid;
35
+ private static PipeSecurity Security;
36
+
37
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
38
+ private static extern bool GetNamedPipeClientComputerName(SafePipeHandle pipe, StringBuilder computerName, uint length);
39
+
40
+ private static void Emit(string frame)
41
+ {
42
+ lock (OutputLock)
43
+ {
44
+ Console.Out.WriteLine(frame);
45
+ Console.Out.Flush();
46
+ }
47
+ }
48
+
49
+ private static string B64(string value)
50
+ {
51
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
52
+ }
53
+
54
+ private static void EmitFatal(Exception error)
55
+ {
56
+ Emit("FATAL\t" + B64(error.GetBaseException().Message));
57
+ }
58
+
59
+ private static PipeSecurity BuildSecurity(SecurityIdentifier sid)
60
+ {
61
+ PipeSecurity security = new PipeSecurity();
62
+ security.SetAccessRuleProtection(true, false);
63
+ security.AddAccessRule(new PipeAccessRule(sid, PipeAccessRights.FullControl, AccessControlType.Allow));
64
+ return security;
65
+ }
66
+
67
+ private static void VerifySecurity(NamedPipeServerStream pipe)
68
+ {
69
+ PipeSecurity actual = pipe.GetAccessControl();
70
+ AuthorizationRuleCollection rules = actual.GetAccessRules(true, true, typeof(SecurityIdentifier));
71
+ if (rules.Count != 1)
72
+ throw new InvalidOperationException("created pipe DACL has an unexpected number of ACEs");
73
+ PipeAccessRule rule = rules[0] as PipeAccessRule;
74
+ if (rule == null || rule.AccessControlType != AccessControlType.Allow ||
75
+ !UserSid.Equals(rule.IdentityReference) || rule.PipeAccessRights != PipeAccessRights.FullControl ||
76
+ rule.InheritanceFlags != InheritanceFlags.None || rule.PropagationFlags != PropagationFlags.None ||
77
+ rule.IsInherited)
78
+ throw new InvalidOperationException("created pipe DACL is not exactly current-user full control");
79
+ if (!actual.AreAccessRulesProtected)
80
+ throw new InvalidOperationException("created pipe DACL is not protected from inherited ACEs");
81
+ }
82
+
83
+ private static NamedPipeServerStream CreatePipe(bool first)
84
+ {
85
+ PipeOptions options = PipeOptions.Asynchronous;
86
+ string[] names = Enum.GetNames(typeof(PipeOptions));
87
+ if (first && names.Contains("FirstPipeInstance"))
88
+ options |= (PipeOptions)Enum.Parse(typeof(PipeOptions), "FirstPipeInstance");
89
+
90
+ int instances = 254;
91
+ Type aclFactory = Type.GetType("System.IO.Pipes.NamedPipeServerStreamAcl, System.IO.Pipes.AccessControl", false);
92
+ if (aclFactory != null)
93
+ {
94
+ MethodInfo method = aclFactory.GetMethods(BindingFlags.Public | BindingFlags.Static)
95
+ .Where(m => m.Name == "Create")
96
+ .FirstOrDefault(m => {
97
+ ParameterInfo[] p = m.GetParameters();
98
+ return p.Length >= 8 && p[0].ParameterType == typeof(string) && p[7].ParameterType == typeof(PipeSecurity);
99
+ });
100
+ if (method != null)
101
+ {
102
+ ParameterInfo[] parameters = method.GetParameters();
103
+ object[] args = new object[parameters.Length];
104
+ args[0] = PipeName;
105
+ args[1] = PipeDirection.InOut;
106
+ args[2] = instances;
107
+ args[3] = PipeTransmissionMode.Byte;
108
+ args[4] = options;
109
+ args[5] = 65536;
110
+ args[6] = 65536;
111
+ args[7] = Security;
112
+ for (int i = 8; i < args.Length; i++)
113
+ args[i] = parameters[i].DefaultValue;
114
+ return (NamedPipeServerStream)method.Invoke(null, args);
115
+ }
116
+ }
117
+
118
+ ConstructorInfo constructor = typeof(NamedPipeServerStream).GetConstructor(new Type[] {
119
+ typeof(string), typeof(PipeDirection), typeof(int), typeof(PipeTransmissionMode),
120
+ typeof(PipeOptions), typeof(int), typeof(int), typeof(PipeSecurity)
121
+ });
122
+ if (constructor == null)
123
+ throw new PlatformNotSupportedException("this PowerShell runtime has no ACL-capable named pipe constructor");
124
+ return (NamedPipeServerStream)constructor.Invoke(new object[] {
125
+ PipeName, PipeDirection.InOut, instances, PipeTransmissionMode.Byte,
126
+ options, 65536, 65536, Security
127
+ });
128
+ }
129
+
130
+ private static bool IsLocalClient(NamedPipeServerStream pipe)
131
+ {
132
+ StringBuilder clientName = new StringBuilder(256);
133
+ uint bufferLengthBytes = (uint)(clientName.Capacity * 2);
134
+ if (!GetNamedPipeClientComputerName(pipe.SafePipeHandle, clientName, bufferLengthBytes))
135
+ {
136
+ // ERROR_PIPE_LOCAL (229), not ERROR_PIPE_NOT_CONNECTED (233),
137
+ // means the client connected through the local NPFS path.
138
+ int error = Marshal.GetLastWin32Error();
139
+ if (error == 229) return true;
140
+ throw new InvalidOperationException("could not verify the named-pipe client computer (Win32 error " + error + ")");
141
+ }
142
+ // A successful lookup identifies a remote SMB client.
143
+ Emit("REJECT\t" + B64("remote named-pipe client computer " + clientName));
144
+ return false;
145
+ }
146
+
147
+ private static void Relay(int id, NamedPipeServerStream pipe)
148
+ {
149
+ try
150
+ {
151
+ byte[] buffer = new byte[32768];
152
+ while (!Stopping && pipe.IsConnected)
153
+ {
154
+ int count = pipe.Read(buffer, 0, buffer.Length);
155
+ if (count <= 0) break;
156
+ string data = Convert.ToBase64String(buffer, 0, count);
157
+ Emit("DATA\t" + id + "\t" + data);
158
+ }
159
+ }
160
+ catch (ObjectDisposedException) { }
161
+ catch (IOException) { }
162
+ finally
163
+ {
164
+ NamedPipeServerStream ignored;
165
+ Connections.TryRemove(id, out ignored);
166
+ try { pipe.Dispose(); } catch { }
167
+ Emit("CLOSE\t" + id);
168
+ }
169
+ }
170
+
171
+ private static void AcceptLoop(NamedPipeServerStream first)
172
+ {
173
+ NamedPipeServerStream listener = first;
174
+ try
175
+ {
176
+ AcceptStarted.Wait();
177
+ while (!Stopping)
178
+ {
179
+ listener.WaitForConnection();
180
+ if (!IsLocalClient(listener))
181
+ {
182
+ listener.Dispose();
183
+ listener = CreatePipe(false);
184
+ VerifySecurity(listener);
185
+ continue;
186
+ }
187
+
188
+ NamedPipeServerStream connected = listener;
189
+ listener = CreatePipe(false);
190
+ VerifySecurity(listener);
191
+ int id = Interlocked.Increment(ref NextConnectionId);
192
+ Connections[id] = connected;
193
+ Emit("OPEN\t" + id);
194
+ Task.Run(() => Relay(id, connected));
195
+ }
196
+ }
197
+ catch (ObjectDisposedException error)
198
+ {
199
+ if (!Stopping)
200
+ {
201
+ EmitFatal(error);
202
+ Stopping = true;
203
+ foreach (NamedPipeServerStream pipe in Connections.Values)
204
+ try { pipe.Dispose(); } catch { }
205
+ try { listener.Dispose(); } catch { }
206
+ Environment.ExitCode = 1;
207
+ }
208
+ }
209
+ catch (Exception error)
210
+ {
211
+ EmitFatal(error);
212
+ Stopping = true;
213
+ foreach (NamedPipeServerStream pipe in Connections.Values)
214
+ try { pipe.Dispose(); } catch { }
215
+ try { listener.Dispose(); } catch { }
216
+ Environment.ExitCode = 1;
217
+ }
218
+ }
219
+
220
+ private static void ReadControl()
221
+ {
222
+ string line;
223
+ while (!Stopping && (line = Console.In.ReadLine()) != null)
224
+ {
225
+ string[] fields = line.Split('\t');
226
+ if (fields[0] == "START")
227
+ {
228
+ AcceptStarted.Set();
229
+ continue;
230
+ }
231
+ if (fields.Length < 2) continue;
232
+ int id;
233
+ if (!Int32.TryParse(fields[1], out id)) continue;
234
+ NamedPipeServerStream pipe;
235
+ if (!Connections.TryGetValue(id, out pipe)) continue;
236
+ try
237
+ {
238
+ if (fields[0] == "WRITE" && fields.Length == 3)
239
+ {
240
+ byte[] bytes = Convert.FromBase64String(fields[2]);
241
+ pipe.Write(bytes, 0, bytes.Length);
242
+ pipe.Flush();
243
+ }
244
+ else if (fields[0] == "CLOSE")
245
+ {
246
+ pipe.Dispose();
247
+ }
248
+ }
249
+ catch (ObjectDisposedException) { }
250
+ catch (IOException) { }
251
+ }
252
+ Stopping = true;
253
+ foreach (NamedPipeServerStream pipe in Connections.Values)
254
+ try { pipe.Dispose(); } catch { }
255
+ }
256
+
257
+ public static void Run(string pipeName)
258
+ {
259
+ PipeName = pipeName;
260
+ UserSid = WindowsIdentity.GetCurrent().User;
261
+ if (UserSid == null) throw new InvalidOperationException("current Windows user SID is unavailable");
262
+ Security = BuildSecurity(UserSid);
263
+ NamedPipeServerStream first = CreatePipe(true);
264
+ VerifySecurity(first);
265
+ string sddl = first.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access);
266
+ Emit("READY\t" + UserSid.Value + "\t" + B64(sddl));
267
+ Task accept = Task.Run(() => AcceptLoop(first));
268
+ ReadControl();
269
+ Stopping = true;
270
+ try { first.Dispose(); } catch { }
271
+ try { accept.Wait(1000); } catch { }
272
+ }
273
+ }
274
+ '@
275
+
276
+ try {
277
+ Add-Type -TypeDefinition $source -ErrorAction Stop
278
+ [HqdWindowsPipeHost]::Run($env:HQD_PIPE_NAME)
279
+ } catch {
280
+ [Console]::Error.WriteLine($_.Exception.GetBaseException().Message)
281
+ exit 1
282
+ }
@@ -27,7 +27,9 @@
27
27
  * a feature that simply is not turned on.
28
28
  */
29
29
  import chalk from "chalk";
30
+ import { diagnosticsEnabled } from "../lib/diagnostics.js";
30
31
  import { ensureCognitoToken } from "../utils/cognito-session.js";
32
+ import { isCompanySelectionError } from "../utils/company-selection-error.js";
31
33
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
32
34
  /** The request kind this command files. Matches hq-pro's contract. */
33
35
  export const PLAN_RESOURCE = "plan";
@@ -45,6 +47,20 @@ export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a car
45
47
  class SponsorError extends Error {
46
48
  expected = true;
47
49
  }
50
+ function isExpectedCompanyResolveFailure(error) {
51
+ if (isCompanySelectionError(error))
52
+ return true;
53
+ return (typeof error === "object" &&
54
+ error !== null &&
55
+ "expected" in error &&
56
+ error.expected === true);
57
+ }
58
+ function noteSponsorCompanyUnresolved(error) {
59
+ if (isExpectedCompanyResolveFailure(error) || !diagnosticsEnabled())
60
+ return;
61
+ const errorName = error instanceof Error ? error.name : "Error";
62
+ console.error(`billing-sponsor company resolve failed errorName=${errorName}`);
63
+ }
48
64
  function fail(err) {
49
65
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
50
66
  process.exit(1);
@@ -185,7 +201,10 @@ export function registerBillingSponsorCommands(billing) {
185
201
  // this feature is for often belongs to no company at all, and they
186
202
  // still need to be told what to do next.
187
203
  const named = opts.company ?? billing.opts().company;
188
- const companyUid = await getCompanyUid(token, named).catch(() => null);
204
+ const companyUid = await getCompanyUid(token, named).catch((error) => {
205
+ noteSponsorCompanyUnresolved(error);
206
+ return null;
207
+ });
189
208
  // The admin queue is the richer view. A non-admin is refused it, so we
190
209
  // fall back to the member SELF-READ — a different route with a
191
210
  // different answer: hq-pro returns ONE status for that person and that
@@ -15,6 +15,8 @@ export type DeriveTriggerRunResult = {
15
15
  stdout: Buffer;
16
16
  stderr: Buffer;
17
17
  };
18
+ export declare function resolveWindowsFilesystemPath(candidate: string, cwd: string, resolveRealpath?: (value: string) => string, isDirectory?: (value: string) => boolean): string | undefined;
19
+ export declare function commandOnPath(command: string, env: Record<string, string | undefined>, platform?: NodeJS.Platform): boolean;
18
20
  /** Run the native handler contract, retaining exact bytes for differential tests. */
19
21
  export declare function runDeriveTriggerFacts(options: DeriveTriggerRunOptions): DeriveTriggerRunResult;
20
22
  export declare function runDeriveTriggerFactsFast(argv: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): number;
@@ -4,7 +4,6 @@ import * as path from "node:path";
4
4
  import { runBundledScript } from "../utils/run-bundled-script.js";
5
5
  import { isByteOrientedLocale } from "../lib/core-utils/byte-oriented-locale.js";
6
6
  export const DERIVE_TRIGGER_FACTS_ASSET = "core/scripts/derive-trigger-facts.sh";
7
- const TEXT_FACT_AWK = "core/scripts/lib/trigger-fact-text.awk";
8
7
  const TRANSCRIPT_TAIL_MAX_BYTES = 1_048_576;
9
8
  function effectiveEnvironment(options) {
10
9
  return {
@@ -62,13 +61,51 @@ function globSegmentMatches(pattern, value) {
62
61
  return false;
63
62
  }
64
63
  }
64
+ export function resolveWindowsFilesystemPath(candidate, cwd, resolveRealpath = (value) => fs.realpathSync.native(value), isDirectory = (value) => fs.statSync(value).isDirectory()) {
65
+ const value = candidate || ".";
66
+ // Node's win32 path helpers collapse `link/..` lexically before the OS can
67
+ // follow `link`. Bash walks the path component by component, so resolve a
68
+ // symlink before applying each parent component while keeping the emitted
69
+ // glob word untouched.
70
+ // Git Bash exposes mounted Windows drives as /c/... while Node's win32
71
+ // path parser treats that spelling as a path rooted at the current drive.
72
+ // Convert only the drive-mount prefix before probing the filesystem.
73
+ const windowsValue = value.replace(/^\/([A-Za-z])(?:\/|$)/, (_match, drive) => `${drive.toUpperCase()}:\\`);
74
+ const absolute = path.win32.isAbsolute(windowsValue);
75
+ const parsed = path.win32.parse(absolute ? windowsValue : cwd);
76
+ let current = absolute ? parsed.root : cwd;
77
+ const rest = absolute ? windowsValue.slice(parsed.root.length) : windowsValue;
78
+ for (const segment of rest.split(/[\\/]+/)) {
79
+ if (!segment || segment === ".")
80
+ continue;
81
+ if (segment === "..") {
82
+ try {
83
+ current = resolveRealpath(current);
84
+ if (!isDirectory(current))
85
+ return undefined;
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ current = path.win32.dirname(current);
91
+ }
92
+ else {
93
+ current = path.win32.join(current, segment);
94
+ }
95
+ }
96
+ return current;
97
+ }
65
98
  function filesystemPath(candidate, cwd) {
66
99
  const value = candidate || ".";
67
- return path.isAbsolute(value) ? value : `${cwd}${path.sep}${value}`;
100
+ if (process.platform !== "win32") {
101
+ return path.isAbsolute(value) ? value : `${cwd}${path.sep}${value}`;
102
+ }
103
+ return resolveWindowsFilesystemPath(value, cwd);
68
104
  }
69
105
  function isDirectoryPath(candidate, cwd) {
70
106
  try {
71
- return fs.statSync(filesystemPath(candidate, cwd)).isDirectory();
107
+ const resolved = filesystemPath(candidate, cwd);
108
+ return resolved !== undefined && fs.statSync(resolved).isDirectory();
72
109
  }
73
110
  catch {
74
111
  return false;
@@ -76,7 +113,10 @@ function isDirectoryPath(candidate, cwd) {
76
113
  }
77
114
  function pathEntryExists(candidate, cwd) {
78
115
  try {
79
- fs.lstatSync(filesystemPath(candidate, cwd));
116
+ const resolved = filesystemPath(candidate, cwd);
117
+ if (resolved === undefined)
118
+ return false;
119
+ fs.lstatSync(resolved);
80
120
  return true;
81
121
  }
82
122
  catch {
@@ -86,16 +126,17 @@ function pathEntryExists(candidate, cwd) {
86
126
  function expandBashGlob(token, cwd) {
87
127
  if (!hasGlobPattern(token))
88
128
  return [token];
89
- const absolute = token.startsWith(path.sep);
90
- const trailingSlash = token.endsWith(path.sep);
91
- const parts = token.split(path.sep).filter((part, index) => !(absolute && index === 0));
129
+ // Bash emits slash-separated words even when running under Git Bash on Windows.
130
+ const absolute = token.startsWith("/");
131
+ const trailingSlash = token.endsWith("/");
132
+ const parts = token.split("/").filter((part, index) => !(absolute && index === 0));
92
133
  if (trailingSlash)
93
134
  parts.pop();
94
- let candidates = [absolute ? path.sep : ""];
135
+ let candidates = [absolute ? "/" : ""];
95
136
  let matchedPattern = false;
96
137
  for (const [index, part] of parts.entries()) {
97
138
  if (!hasGlobPattern(part)) {
98
- candidates = candidates.map((parent) => !parent ? part : parent === path.sep ? `${path.sep}${part}` : `${parent}${path.sep}${part}`);
139
+ candidates = candidates.map((parent) => !parent ? part : parent === "/" ? `/${part}` : `${parent}/${part}`);
99
140
  if (matchedPattern) {
100
141
  const needsDirectory = index < parts.length - 1 || trailingSlash;
101
142
  candidates = candidates.filter((candidate) => needsDirectory ? isDirectoryPath(candidate, cwd) : pathEntryExists(candidate, cwd));
@@ -106,6 +147,8 @@ function expandBashGlob(token, cwd) {
106
147
  const expanded = [];
107
148
  for (const parent of candidates) {
108
149
  const directory = filesystemPath(parent || ".", cwd);
150
+ if (directory === undefined)
151
+ continue;
109
152
  let names;
110
153
  try {
111
154
  names = fs.readdirSync(directory).sort();
@@ -118,7 +161,7 @@ function expandBashGlob(token, cwd) {
118
161
  continue;
119
162
  if (!globSegmentMatches(part, name))
120
163
  continue;
121
- expanded.push(!parent ? name : parent === path.sep ? `${path.sep}${name}` : `${parent}${path.sep}${name}`);
164
+ expanded.push(!parent ? name : parent === "/" ? `/${name}` : `${parent}/${name}`);
122
165
  }
123
166
  }
124
167
  if (expanded.length === 0)
@@ -128,7 +171,7 @@ function expandBashGlob(token, cwd) {
128
171
  if (trailingSlash) {
129
172
  candidates = candidates
130
173
  .filter((candidate) => isDirectoryPath(candidate, cwd))
131
- .map((candidate) => candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`)
174
+ .map((candidate) => candidate.endsWith("/") ? candidate : `${candidate}/`)
132
175
  .sort();
133
176
  }
134
177
  return matchedPattern && candidates.length > 0 ? candidates : [token];
@@ -136,16 +179,27 @@ function expandBashGlob(token, cwd) {
136
179
  function bashUnquotedWords(facts, cwd) {
137
180
  return facts.join(" ").split(/[\t\n ]+/).filter(Boolean).flatMap((word) => expandBashGlob(word, cwd));
138
181
  }
139
- function commandOnPath(command, env) {
182
+ export function commandOnPath(command, env, platform = process.platform) {
140
183
  const pathValue = env.PATH ?? "";
184
+ const extensions = platform === "win32"
185
+ ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
186
+ : [""];
141
187
  for (const directory of pathValue.split(path.delimiter)) {
142
- const candidate = path.join(directory || process.cwd(), command);
143
- try {
144
- fs.accessSync(candidate, fs.constants.X_OK);
145
- return true;
146
- }
147
- catch {
148
- // PATH probes intentionally ignore missing and inaccessible entries.
188
+ for (const extension of extensions) {
189
+ const candidate = path.join(directory || process.cwd(), `${command}${extension}`);
190
+ try {
191
+ if (platform === "win32") {
192
+ if (fs.statSync(candidate).isFile())
193
+ return true;
194
+ }
195
+ else {
196
+ fs.accessSync(candidate, fs.constants.X_OK);
197
+ return true;
198
+ }
199
+ }
200
+ catch {
201
+ // PATH probes intentionally ignore missing and inaccessible entries.
202
+ }
149
203
  }
150
204
  }
151
205
  return false;
@@ -185,10 +239,12 @@ function parsePayload(raw) {
185
239
  parsed = JSON.parse(raw);
186
240
  }
187
241
  catch {
188
- throw new ShellFallbackRequired("malformed hook JSON");
242
+ // The shell redirects jq's parse diagnostic and leaves every field empty.
243
+ return emptyPayload();
189
244
  }
190
245
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
191
- throw new ShellFallbackRequired("unexpected hook payload shape");
246
+ // jq's object indexing fails quietly through the same redirected pipeline.
247
+ return emptyPayload();
192
248
  }
193
249
  const record = parsed;
194
250
  const toolResponse = jqField(record.tool_response);
@@ -207,6 +263,12 @@ function parsePayload(raw) {
207
263
  }
208
264
  return fields;
209
265
  }
266
+ function emptyPayload() {
267
+ return {
268
+ tool: "", command: "", runInBackground: "", toolResponse: "", prompt: "",
269
+ cwd: "", sessionId: "", transcriptPath: "",
270
+ };
271
+ }
210
272
  function matchTextFacts(rawText, env) {
211
273
  const text = localeLower(rawText, env);
212
274
  const facts = [];
@@ -391,9 +453,8 @@ function computeOutput(options) {
391
453
  const withAssistantIntent = args[1] === "--with-assistant-intent";
392
454
  if (args.length === 2 && !withAssistantIntent)
393
455
  throw new ShellFallbackRequired("unsupported argv");
394
- if (!["PreToolUse", "PostToolUse", "UserPromptSubmit", "AssistantIntent"].includes(event)) {
395
- throw new ShellFallbackRequired("unsupported event");
396
- }
456
+ // The shell accepts unknown event names and emits the baseline `always`
457
+ // fact (plus any static session facts); it does not validate the event.
397
458
  const paired = withAssistantIntent && (event === "PreToolUse" || event === "UserPromptSubmit");
398
459
  const env = effectiveEnvironment(options);
399
460
  const jqAvailable = options.jqAvailable ?? commandOnPath("jq", env);
@@ -465,6 +526,7 @@ function shellFallback(options) {
465
526
  args: [...options.argv],
466
527
  cwd: options.hqRoot,
467
528
  hqRoot: options.hqRoot,
529
+ msysScriptPath: true,
468
530
  stdin: options.stdin,
469
531
  captureOutput: true,
470
532
  env,
@@ -487,12 +549,17 @@ function missingSupportLibrary(hqRoot) {
487
549
  ],
488
550
  ];
489
551
  for (const [relativePath, message] of required) {
552
+ let descriptor;
490
553
  try {
491
- fs.accessSync(path.join(hqRoot, relativePath), fs.constants.R_OK);
554
+ descriptor = fs.openSync(path.join(hqRoot, relativePath), "r");
492
555
  }
493
556
  catch {
494
557
  return message;
495
558
  }
559
+ finally {
560
+ if (descriptor !== undefined)
561
+ fs.closeSync(descriptor);
562
+ }
496
563
  }
497
564
  return undefined;
498
565
  }
@@ -12,6 +12,7 @@ export type MigratePolicyDependencies = {
12
12
  error?: Error;
13
13
  };
14
14
  };
15
+ export declare function executableInPath(name: string, env: NodeJS.ProcessEnv, fileSystem: typeof fs, platform?: NodeJS.Platform): boolean;
15
16
  export declare function runMigratePolicyTriggers(args: readonly string[], deps: MigratePolicyDependencies): {
16
17
  code: number;
17
18
  stdout: string;
@@ -57,16 +57,24 @@ function isDirectory(fileSystem, value) {
57
57
  return false;
58
58
  }
59
59
  }
60
- function executableInPath(name, env, fileSystem) {
60
+ export function executableInPath(name, env, fileSystem, platform = process.platform) {
61
+ const extensions = platform === "win32"
62
+ // spawnSync("flock", ...) without shell support can launch native images,
63
+ // but not batch files. Treating .cmd/.bat as available skips the fallback
64
+ // lock while the later spawn fails on Windows.
65
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => /^\.(?:COM|EXE)$/i.test(extension))
66
+ : [""];
61
67
  for (const directory of (env.PATH ?? "").split(path.delimiter)) {
62
68
  if (!directory)
63
69
  continue;
64
- try {
65
- const stat = fileSystem.statSync(path.join(directory, name));
66
- if (stat.isFile() && (stat.mode & 0o111) !== 0)
67
- return true;
70
+ for (const extension of extensions) {
71
+ try {
72
+ const stat = fileSystem.statSync(path.join(directory, `${name}${extension}`));
73
+ if (stat.isFile() && (platform === "win32" || (stat.mode & 0o111) !== 0))
74
+ return true;
75
+ }
76
+ catch { /* continue through PATH */ }
68
77
  }
69
- catch { /* continue through PATH */ }
70
78
  }
71
79
  return false;
72
80
  }
@@ -79,7 +87,9 @@ function runNonblockingFlock(lockFd, cwd, pathValue) {
79
87
  }
80
88
  function frontmatterValue(content, key) {
81
89
  let depth = 0;
82
- for (const line of content.split("\n")) {
90
+ for (const rawLine of content.split("\n")) {
91
+ // Git for Windows' awk reads CRLF as a line ending; POSIX awk keeps the CR.
92
+ const line = process.platform === "win32" ? rawLine.replace(/\r$/, "") : rawLine;
83
93
  if (line === "---") {
84
94
  depth += 1;
85
95
  continue;
@@ -154,10 +164,11 @@ function deriveWhen(trigger, rawTags, enforcementRaw) {
154
164
  function writeAtomic(fileSystem, target, bytes) {
155
165
  const temporary = path.join(path.dirname(target), `.migrate-policy-triggers.${process.pid}.${Math.random().toString(16).slice(2)}`);
156
166
  // The shell writes through mktemp, whose private mode survives the rename on both platforms.
157
- const mode = 0o600;
167
+ const mode = process.platform === "win32" ? undefined : 0o600;
158
168
  try {
159
- fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", mode, flag: "wx" });
160
- fileSystem.chmodSync(temporary, mode);
169
+ fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", ...(mode === undefined ? {} : { mode }), flag: "wx" });
170
+ if (mode !== undefined)
171
+ fileSystem.chmodSync(temporary, mode);
161
172
  fileSystem.renameSync(temporary, target);
162
173
  return true;
163
174
  }
@@ -447,7 +458,7 @@ export function runMigratePolicyTriggers(args, deps) {
447
458
  continue;
448
459
  }
449
460
  const anchor = original.split("\n").some((line) => line.startsWith("trigger:")) ? /^trigger:/ : /^id:/;
450
- const lines = original.split("\n");
461
+ const lines = original.split("\n").map((line) => process.platform === "win32" ? line.replace(/\r$/, "") : line);
451
462
  let inserted = false;
452
463
  const rewritten = lines.flatMap((line) => {
453
464
  if (!inserted && anchor.test(line)) {
@@ -471,7 +482,10 @@ export function runMigratePolicyTriggers(args, deps) {
471
482
  if (!dryRun && stateReady && !errorCode) {
472
483
  const stampTmp = path.join(path.dirname(stamp), `.last-success.${process.pid}.${Math.random().toString(16).slice(2)}`);
473
484
  try {
474
- fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, { mode: 0o600, flag: "wx" });
485
+ fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, {
486
+ ...(process.platform === "win32" ? {} : { mode: 0o600 }),
487
+ flag: "wx",
488
+ });
475
489
  fileSystem.renameSync(stampTmp, stamp);
476
490
  }
477
491
  catch {