vibe-coding-master 0.9.6 → 0.9.7

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
@@ -17,12 +17,30 @@ orchestration, translation, harness setup, and mobile gateway control.
17
17
 
18
18
  ## Requirements
19
19
 
20
- - Node.js 20 LTS or 22+
20
+ - Node.js 20 LTS or 22+, with `node` on the `PATH` VCM starts with (role hooks
21
+ and `.ai/tools` scripts run with it)
21
22
  - npm
22
- - Git
23
+ - Git 2.22 or newer
24
+ - `sh` and `curl` (role hooks report Claude Code events to VCM through them)
25
+ - Non-interactive credentials for the repository's upstream remote (an SSH key
26
+ in ssh-agent or a Git credential helper). Creating a task pulls the upstream
27
+ branch, and VCM runs Git without credential prompts.
28
+ - Optional: GitHub CLI (`gh`). VCM never runs it, but when it is installed and
29
+ logged out VCM warns, because roles that use it will fail.
23
30
  - Claude Code installed and available as `claude` (verified with 2.1.310 or
24
31
  newer; VCM shows a Repository warning when an older release is detected)
25
32
  - Claude Code authenticated in the same environment where VCM runs
33
+ - A Git commit identity (`user.name` and `user.email`) in the same environment.
34
+ When either is missing, VCM and its roles commit as `VCM <vcm@localhost>`
35
+ and the Repository panel shows a warning; VCM never changes your Git config.
36
+
37
+ VCM checks these programs at startup, runs a hook self-test that sends one
38
+ request through the installed hook pipeline, and checks access to the upstream
39
+ remote when a repository connects. Any problem appears in the startup log and
40
+ the Repository warnings, and the Repository header shows how many there are
41
+ while the section is collapsed; none of them stops VCM from running. Role sessions
42
+ add the VCM host to `NO_PROXY`, so an `http_proxy` setting does not capture
43
+ their hook requests.
26
44
 
27
45
  For Linux containers, make sure common native build/runtime tools are available:
28
46
 
@@ -10,10 +10,15 @@ export function createCommandRunner() {
10
10
  input: options.input,
11
11
  reject: false
12
12
  });
13
+ // A command that could not start, timed out, or died from a signal has
14
+ // no exit code; it failed, so it must not read as a clean exit.
15
+ const exitCode = result.exitCode ?? (result.failed ? 1 : 0);
13
16
  return {
14
17
  stdout: result.stdout,
15
- stderr: result.stderr,
16
- exitCode: result.exitCode ?? 0
18
+ stderr: result.exitCode === undefined && result.failed
19
+ ? [result.stderr, result.shortMessage].filter(Boolean).join("\n")
20
+ : result.stderr,
21
+ exitCode
17
22
  };
18
23
  }
19
24
  catch (error) {
@@ -1,7 +1,24 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { VcmError } from "../errors.js";
4
+ // Network Git commands run without a terminal prompt: VCM is driven from the
5
+ // browser, so a credential prompt would hang the request on the terminal that
6
+ // started VCM instead of failing with an error the UI can show.
7
+ const REMOTE_COMMAND_TIMEOUT_MS = 20 * 1000;
8
+ const PULL_TIMEOUT_MS = 5 * 60 * 1000;
9
+ // Commit identity used when neither Git config nor the environment provides one.
10
+ // Without it, Git either refuses to commit or invents an identity from the OS
11
+ // account and hostname, which silently attributes commits to a machine name.
12
+ export const DEFAULT_GIT_IDENTITY = { name: "VCM", email: "vcm@localhost" };
13
+ const GIT_IDENTITY_CACHE_TTL_MS = 30 * 1000;
14
+ export function renderGitIdentityWarning(location, identity) {
15
+ const missing = identity.defaulted.map((part) => `user.${part}`).join(" and ");
16
+ return `Git ${missing} is not configured for ${location}, so VCM and role commits use ${identity.name} <${identity.email}>. `
17
+ + "Set your own identity in the environment that runs VCM: "
18
+ + "git config --global user.name \"<name>\" and git config --global user.email \"<email>\".";
19
+ }
4
20
  export function createGitAdapter(runner) {
21
+ const identityCache = new Map();
5
22
  return {
6
23
  async checkRepo(repoRoot) {
7
24
  return checkGitMarker(repoRoot);
@@ -303,7 +320,7 @@ export function createGitAdapter(runner) {
303
320
  }
304
321
  },
305
322
  async commit(repoRoot, message) {
306
- const result = await runGit(runner, repoRoot, ["commit", "-m", message]);
323
+ const result = await runGit(runner, repoRoot, ["commit", "-m", message], (await this.resolveCommitIdentity(repoRoot)).env);
307
324
  if (result.exitCode !== 0) {
308
325
  throw new VcmError({
309
326
  code: "GIT_COMMIT_FAILED",
@@ -318,7 +335,7 @@ export function createGitAdapter(runner) {
318
335
  if (paths.length === 0) {
319
336
  return this.getHeadCommit(repoRoot);
320
337
  }
321
- const result = await runGit(runner, repoRoot, ["commit", "--only", "-m", message, "--", ...paths]);
338
+ const result = await runGit(runner, repoRoot, ["commit", "--only", "-m", message, "--", ...paths], (await this.resolveCommitIdentity(repoRoot)).env);
322
339
  if (result.exitCode !== 0) {
323
340
  throw new VcmError({
324
341
  code: "GIT_COMMIT_FAILED",
@@ -329,6 +346,29 @@ export function createGitAdapter(runner) {
329
346
  }
330
347
  return this.getHeadCommit(repoRoot);
331
348
  },
349
+ async resolveCommitIdentity(repoRoot) {
350
+ const cached = identityCache.get(repoRoot);
351
+ if (cached && Date.now() - cached.checkedAt < GIT_IDENTITY_CACHE_TTL_MS) {
352
+ return cached.identity;
353
+ }
354
+ const identity = { name: "", email: "", defaulted: [], env: {} };
355
+ for (const part of ["name", "email"]) {
356
+ const result = await runGit(runner, repoRoot, ["config", "--get", `user.${part}`]);
357
+ const configured = result.exitCode === 0 ? result.stdout.trim() : "";
358
+ const variables = [`GIT_AUTHOR_${part.toUpperCase()}`, `GIT_COMMITTER_${part.toUpperCase()}`];
359
+ for (const variable of variables) {
360
+ if (!configured && !process.env[variable]) {
361
+ identity.env[variable] = DEFAULT_GIT_IDENTITY[part];
362
+ }
363
+ }
364
+ if (variables.some((variable) => identity.env[variable])) {
365
+ identity.defaulted.push(part);
366
+ }
367
+ identity[part] = process.env[variables[0]] || configured || DEFAULT_GIT_IDENTITY[part];
368
+ }
369
+ identityCache.set(repoRoot, { checkedAt: Date.now(), identity });
370
+ return identity;
371
+ },
332
372
  async createWorktree(input) {
333
373
  const result = await runGit(runner, input.repoRoot, [
334
374
  "worktree",
@@ -375,7 +415,7 @@ export function createGitAdapter(runner) {
375
415
  }
376
416
  },
377
417
  async pullFastForward(repoRoot) {
378
- const result = await runGit(runner, repoRoot, ["pull", "--ff-only"]);
418
+ const result = await runGit(runner, repoRoot, ["pull", "--ff-only"], await buildNonInteractiveGitEnv(runner, repoRoot), PULL_TIMEOUT_MS);
379
419
  if (result.exitCode !== 0) {
380
420
  throw new VcmError({
381
421
  code: "GIT_PULL_FAILED",
@@ -388,9 +428,47 @@ export function createGitAdapter(runner) {
388
428
  stdout: result.stdout,
389
429
  stderr: result.stderr
390
430
  };
431
+ },
432
+ async checkRemoteAccess(repoRoot) {
433
+ const branch = (await runGit(runner, repoRoot, ["branch", "--show-current"])).stdout.trim();
434
+ if (!branch) {
435
+ return { status: "skipped" };
436
+ }
437
+ const remoteResult = await runGit(runner, repoRoot, ["config", "--get", `branch.${branch}.remote`]);
438
+ const remote = remoteResult.stdout.trim();
439
+ if (remoteResult.exitCode !== 0 || !remote || remote === ".") {
440
+ return { status: "skipped" };
441
+ }
442
+ const result = await runGit(runner, repoRoot, ["ls-remote", remote, "HEAD"], await buildNonInteractiveGitEnv(runner, repoRoot), REMOTE_COMMAND_TIMEOUT_MS);
443
+ if (result.exitCode === 0) {
444
+ return { status: "ok", remote };
445
+ }
446
+ return {
447
+ status: "failed",
448
+ remote,
449
+ detail: firstMeaningfulLine(result.stderr) || firstMeaningfulLine(result.stdout) || `git ls-remote exited with ${result.exitCode}`
450
+ };
391
451
  }
392
452
  };
393
453
  }
454
+ async function buildNonInteractiveGitEnv(runner, repoRoot) {
455
+ const env = {
456
+ GIT_TERMINAL_PROMPT: "0",
457
+ GCM_INTERACTIVE: "never"
458
+ };
459
+ // Keep a configured SSH command; otherwise make ssh fail instead of asking
460
+ // for a passphrase or host-key confirmation.
461
+ if (!process.env.GIT_SSH_COMMAND && !process.env.GIT_SSH) {
462
+ const configured = await runGit(runner, repoRoot, ["config", "--get", "core.sshCommand"]);
463
+ if (configured.exitCode !== 0 || !configured.stdout.trim()) {
464
+ env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
465
+ }
466
+ }
467
+ return { ...process.env, ...env };
468
+ }
469
+ function firstMeaningfulLine(value) {
470
+ return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
471
+ }
394
472
  async function checkGitMarker(repoRoot) {
395
473
  const markerPath = path.join(repoRoot, ".git");
396
474
  try {
@@ -449,8 +527,8 @@ async function pathExists(targetPath) {
449
527
  return false;
450
528
  }
451
529
  }
452
- async function runGit(runner, repoRoot, args) {
453
- return runner.run("git", [...await buildSafeDirectoryArgs(repoRoot), ...args], { cwd: repoRoot });
530
+ async function runGit(runner, repoRoot, args, env, timeoutMs) {
531
+ return runner.run("git", [...await buildSafeDirectoryArgs(repoRoot), ...args], { cwd: repoRoot, env, timeoutMs });
454
532
  }
455
533
  function parseWorktreePaths(output) {
456
534
  return output
@@ -1,4 +1,9 @@
1
+ import { VCM_HOOK_PROBE_PATH } from "../services/harness-service.js";
1
2
  export function registerClaudeHookRoutes(app, deps) {
3
+ // The hook self-test posts here through the installed hook pipeline.
4
+ app.post(VCM_HOOK_PROBE_PATH, async (request) => {
5
+ return { received: deps.environmentCheck?.receiveHookProbe(request.body?.runtimeSessionToken) ?? false };
6
+ });
2
7
  app.post("/api/hooks/claude-code", async (request) => {
3
8
  return deps.claudeHookService.handleHook(request.body);
4
9
  });
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import Fastify from "fastify";
@@ -10,7 +11,8 @@ import { createCommandRunner } from "./adapters/command-runner.js";
10
11
  import { createCommandDispatcher } from "./services/command-dispatcher.js";
11
12
  import { createClaudeHookService } from "./services/claude-hook-service.js";
12
13
  import { createClaudeVersionCheck } from "./services/claude-version-check.js";
13
- import { createGitAdapter } from "./adapters/git-adapter.js";
14
+ import { createEnvironmentCheck } from "./services/environment-check.js";
15
+ import { createGitAdapter, renderGitIdentityWarning } from "./adapters/git-adapter.js";
14
16
  import { createAppSettingsService } from "./services/app-settings-service.js";
15
17
  import { createCodexBridgeIntegrationService } from "./services/codex-bridge-integration-service.js";
16
18
  import { createAutoMemoryService } from "./services/auto-memory-service.js";
@@ -92,7 +94,7 @@ export async function createServer(deps, options = {}) {
92
94
  appSettings: deps.appSettings,
93
95
  codexBridgeIntegration: deps.codexBridgeIntegration
94
96
  });
95
- registerClaudeHookRoutes(app, { claudeHookService: deps.claudeHookService });
97
+ registerClaudeHookRoutes(app, { claudeHookService: deps.claudeHookService, environmentCheck: deps.environmentCheck });
96
98
  registerGateReviewRoutes(app, {
97
99
  projectService: deps.projectService,
98
100
  gateReviewService: deps.gateReviewService
@@ -215,6 +217,29 @@ async function cleanupRecentTranslationRuntime(deps) {
215
217
  const repoRoots = await deps.projectService.getRecentRepositoryPaths();
216
218
  await Promise.all(repoRoots.map((repoRoot) => deps.translationWorkerService.cleanupStartupRuntime(repoRoot)));
217
219
  }
220
+ async function warnOnEnvironmentProblems(environmentCheck) {
221
+ try {
222
+ for (const warning of await environmentCheck?.getWarnings({ refresh: true }) ?? []) {
223
+ console.warn(`[VCM] ${warning}`);
224
+ }
225
+ }
226
+ catch {
227
+ // Project summaries report environment problems when a repository connects.
228
+ }
229
+ }
230
+ // Projects can still configure their own identity; this only reports the
231
+ // identity VCM falls back to outside any repository-specific Git config.
232
+ async function warnOnDefaultGitIdentity() {
233
+ try {
234
+ const identity = await createGitAdapter(createCommandRunner()).resolveCommitIdentity(os.homedir());
235
+ if (identity.defaulted.length > 0) {
236
+ console.warn(`[VCM] ${renderGitIdentityWarning("the VCM user", identity)}`);
237
+ }
238
+ }
239
+ catch {
240
+ // Project summaries report identity problems per repository.
241
+ }
242
+ }
218
243
  export async function startServer(options = {}) {
219
244
  const host = options.host ?? "127.0.0.1";
220
245
  const port = options.port ?? 4173;
@@ -223,6 +248,8 @@ export async function startServer(options = {}) {
223
248
  });
224
249
  const app = await createServer(deps, options);
225
250
  await app.listen({ host, port });
251
+ await warnOnEnvironmentProblems(deps.environmentCheck);
252
+ await warnOnDefaultGitIdentity();
226
253
  return {
227
254
  url: `http://${host}:${port}`,
228
255
  close() {
@@ -237,6 +264,7 @@ export function createDefaultServerDeps(options = {}) {
237
264
  const runner = createCommandRunner();
238
265
  const git = createGitAdapter(runner);
239
266
  const claude = createClaudeAdapter(runner);
267
+ const environmentCheck = createEnvironmentCheck({ runner, git, apiUrl: options.apiUrl });
240
268
  const appSettings = createAppSettingsService({ fs });
241
269
  const codexBridgeIntegration = createCodexBridgeIntegrationService({
242
270
  settings: appSettings,
@@ -252,12 +280,14 @@ export function createDefaultServerDeps(options = {}) {
252
280
  const projectService = createProjectService({
253
281
  fs,
254
282
  git,
283
+ environmentCheck,
255
284
  appSettings,
256
285
  claudeVersionCheck: createClaudeVersionCheck(claude)
257
286
  });
258
287
  const taskService = createTaskService({ fs, git, artifactService, projectService });
259
288
  const taskWorkflowService = createTaskWorkflowService({ fs });
260
289
  const sessionService = createSessionService({
290
+ gitIdentity: git,
261
291
  fs,
262
292
  runtime,
263
293
  registry,
@@ -326,6 +356,7 @@ export function createDefaultServerDeps(options = {}) {
326
356
  taskService,
327
357
  taskWorkflowService,
328
358
  workflowControlService,
359
+ probeHookDelivery: () => environmentCheck.probeHookDelivery(),
329
360
  onRouteDelivered: ({ repoRoot, taskSlug, message }) => architectRestartService.recordRouteDelivered(repoRoot, taskSlug, message)
330
361
  });
331
362
  const taskLaunchService = createTaskLaunchService({
@@ -509,7 +540,8 @@ export function createDefaultServerDeps(options = {}) {
509
540
  runtime,
510
541
  diagnosticsService,
511
542
  usageAnalyticsService,
512
- workflowControlService
543
+ workflowControlService,
544
+ environmentCheck
513
545
  };
514
546
  }
515
547
  export function getDefaultStaticDir() {
@@ -0,0 +1,245 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { renderVcmHookProbeCommand } from "./harness-service.js";
3
+ import { buildVcmNoProxyEnvironment } from "./no-proxy-environment.js";
4
+ // Oldest Git that supports every command VCM runs (`git branch --show-current`).
5
+ export const MIN_GIT_VERSION = [2, 22, 0];
6
+ // Matches package.json engines; harness tools run with the `node` on PATH.
7
+ export const MIN_NODE_MAJOR = 20;
8
+ const CHECK_CACHE_TTL_MS = 10 * 60 * 1000;
9
+ // A failed check is retried sooner so a fixed environment clears the warning.
10
+ const FAILED_CHECK_TTL_MS = 30 * 1000;
11
+ const COMMAND_TIMEOUT_MS = 10 * 1000;
12
+ // The hook pipeline retries curl twice with a 2 s limit per attempt.
13
+ const HOOK_PROBE_TIMEOUT_MS = 15 * 1000;
14
+ const HOOK_PROBE_SCOPE = "__vcm_hook_probe__";
15
+ const HOOK_PROBE_ROLE = "hook-probe";
16
+ export function createEnvironmentCheck(deps) {
17
+ const now = deps.now ?? Date.now;
18
+ const baseEnv = deps.baseEnv ?? process.env;
19
+ const hookProbeTimeoutMs = deps.hookProbeTimeoutMs ?? HOOK_PROBE_TIMEOUT_MS;
20
+ const pendingProbes = new Map();
21
+ let cached;
22
+ let inFlight;
23
+ const repositoryCache = new Map();
24
+ const repositoryInFlight = new Map();
25
+ return {
26
+ async getWarnings(options = {}) {
27
+ if (!cached || options.refresh) {
28
+ return runOnce();
29
+ }
30
+ const ttl = cached.warnings.length > 0 ? FAILED_CHECK_TTL_MS : CHECK_CACHE_TTL_MS;
31
+ if (now() - cached.checkedAt >= ttl) {
32
+ void runOnce();
33
+ }
34
+ return cached.warnings;
35
+ },
36
+ async getRepositoryWarnings(repoRoot, options = {}) {
37
+ const current = repositoryCache.get(repoRoot);
38
+ if (!current || options.refresh) {
39
+ return runRepositoryOnce(repoRoot);
40
+ }
41
+ const ttl = current.warnings.length > 0 ? FAILED_CHECK_TTL_MS : CHECK_CACHE_TTL_MS;
42
+ if (now() - current.checkedAt >= ttl) {
43
+ void runRepositoryOnce(repoRoot);
44
+ }
45
+ return current.warnings;
46
+ },
47
+ probeHookDelivery,
48
+ receiveHookProbe(probeToken) {
49
+ if (typeof probeToken !== "string") {
50
+ return false;
51
+ }
52
+ const resolve = pendingProbes.get(probeToken);
53
+ if (!resolve) {
54
+ return false;
55
+ }
56
+ pendingProbes.delete(probeToken);
57
+ resolve();
58
+ return true;
59
+ }
60
+ };
61
+ function runOnce() {
62
+ if (inFlight) {
63
+ return inFlight;
64
+ }
65
+ inFlight = (async () => {
66
+ try {
67
+ const warnings = await runChecks();
68
+ cached = { warnings, checkedAt: now() };
69
+ return warnings;
70
+ }
71
+ finally {
72
+ inFlight = undefined;
73
+ }
74
+ })();
75
+ return inFlight;
76
+ }
77
+ function runRepositoryOnce(repoRoot) {
78
+ const running = repositoryInFlight.get(repoRoot);
79
+ if (running) {
80
+ return running;
81
+ }
82
+ const check = (async () => {
83
+ try {
84
+ const warnings = await runRepositoryChecks(repoRoot);
85
+ repositoryCache.set(repoRoot, { warnings, checkedAt: now() });
86
+ return warnings;
87
+ }
88
+ finally {
89
+ repositoryInFlight.delete(repoRoot);
90
+ }
91
+ })();
92
+ repositoryInFlight.set(repoRoot, check);
93
+ return check;
94
+ }
95
+ async function runRepositoryChecks(repoRoot) {
96
+ if (!deps.git) {
97
+ return [];
98
+ }
99
+ const access = await deps.git.checkRemoteAccess(repoRoot);
100
+ if (access.status !== "failed") {
101
+ return [];
102
+ }
103
+ return [
104
+ `Git cannot reach remote "${access.remote}": ${trimPeriod(access.detail)}. Creating a task and Pull fetch the upstream branch first, so both will fail. VCM runs Git without credential prompts; check the remote URL and set up credentials for it in the environment that runs VCM, such as an SSH key loaded in ssh-agent or a Git credential helper.`
105
+ ];
106
+ }
107
+ async function runChecks() {
108
+ const [git, sh, node, curl, gh] = await Promise.all([checkGit(), checkSh(), checkNode(), checkCurl(), checkGh()]);
109
+ const warnings = [git, sh, node, curl, gh].filter((warning) => Boolean(warning));
110
+ // A missing program already explains a failed probe; only probe when the
111
+ // pipeline's programs are present, to catch PATH, proxy, or network faults.
112
+ if (!sh && !node && !curl) {
113
+ const hookWarning = await probeHookDelivery();
114
+ if (hookWarning) {
115
+ warnings.push(hookWarning);
116
+ }
117
+ }
118
+ return warnings;
119
+ }
120
+ async function checkGit() {
121
+ const result = await runCommand("git", ["--version"]);
122
+ if (result.exitCode !== 0) {
123
+ return `Git is not available: ${firstLine(result.stderr)}. VCM needs Git ${formatVersion(MIN_GIT_VERSION)} or newer on PATH to read repositories and create task worktrees.`;
124
+ }
125
+ const version = parseVersion(result.stdout);
126
+ if (!version) {
127
+ return `Git version could not be parsed from "${firstLine(result.stdout)}". VCM needs Git ${formatVersion(MIN_GIT_VERSION)} or newer.`;
128
+ }
129
+ if (compareVersions(version, MIN_GIT_VERSION) < 0) {
130
+ return `Git ${formatVersion(version)} is older than ${formatVersion(MIN_GIT_VERSION)}, the oldest version VCM supports. Upgrade Git.`;
131
+ }
132
+ return undefined;
133
+ }
134
+ async function checkSh() {
135
+ const result = await runCommand("sh", ["-c", "exit 0"]);
136
+ return result.exitCode === 0
137
+ ? undefined
138
+ : `sh is not available: ${firstLine(result.stderr)}. Claude Code runs every VCM role hook through sh.`;
139
+ }
140
+ async function checkNode() {
141
+ const result = await runCommand("node", ["--version"]);
142
+ if (result.exitCode !== 0) {
143
+ return `node is not available on the PATH role sessions inherit: ${firstLine(result.stderr)}. VCM role hooks, Bash and subagent guards, and .ai/tools scripts run with node; add the Node.js bin directory to PATH before starting VCM.`;
144
+ }
145
+ const version = parseVersion(result.stdout);
146
+ if (version && version[0] < MIN_NODE_MAJOR) {
147
+ return `The node on PATH is ${formatVersion(version)}, but VCM role hooks and .ai/tools scripts need Node.js ${MIN_NODE_MAJOR} or newer. Put a newer Node.js first on PATH before starting VCM.`;
148
+ }
149
+ return undefined;
150
+ }
151
+ async function checkCurl() {
152
+ const result = await runCommand("curl", ["--version"]);
153
+ return result.exitCode === 0
154
+ ? undefined
155
+ : `curl is not available: ${firstLine(result.stderr)}. VCM role hooks use curl to report Claude Code events; without it VCM cannot see role turns start or finish. Install curl.`;
156
+ }
157
+ // VCM never runs gh, so a missing gh is not a problem; an installed but
158
+ // logged-out gh breaks roles that use it for pull requests or issues.
159
+ async function checkGh() {
160
+ const version = await runCommand("gh", ["--version"]);
161
+ if (version.exitCode !== 0) {
162
+ return undefined;
163
+ }
164
+ const status = await deps.runner.run("gh", ["auth", "status"], {
165
+ env: { ...baseEnv, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" },
166
+ timeoutMs: COMMAND_TIMEOUT_MS
167
+ });
168
+ if (status.exitCode === 0) {
169
+ return undefined;
170
+ }
171
+ const detail = firstLine(status.stderr) || firstLine(status.stdout) || `gh auth status exited with ${status.exitCode}`;
172
+ return `GitHub CLI (gh) is installed but not logged in: ${detail}. VCM does not run gh itself, but roles that use gh for pull requests or issues will fail. Run gh auth login in the environment that runs VCM.`;
173
+ }
174
+ async function probeHookDelivery() {
175
+ if (!deps.apiUrl) {
176
+ return undefined;
177
+ }
178
+ const probeToken = randomUUID();
179
+ let received = false;
180
+ const arrived = new Promise((resolve) => {
181
+ pendingProbes.set(probeToken, () => {
182
+ received = true;
183
+ resolve();
184
+ });
185
+ });
186
+ try {
187
+ const run = deps.runner.run("sh", ["-c", renderVcmHookProbeCommand()], {
188
+ env: {
189
+ ...baseEnv,
190
+ ...buildVcmNoProxyEnvironment(deps.apiUrl, {}, baseEnv),
191
+ VCM_API_URL: deps.apiUrl,
192
+ VCM_TASK_SLUG: HOOK_PROBE_SCOPE,
193
+ VCM_ROLE: HOOK_PROBE_ROLE,
194
+ VCM_RUNTIME_SESSION_TOKEN: probeToken
195
+ },
196
+ input: "{}",
197
+ timeoutMs: hookProbeTimeoutMs
198
+ });
199
+ // The endpoint records the probe before curl exits, so the command's
200
+ // completion bounds the wait.
201
+ const result = await Promise.race([run, arrived.then(() => undefined)]);
202
+ if (received) {
203
+ return undefined;
204
+ }
205
+ const detail = firstLine(result?.stderr ?? "") || "the request did not arrive";
206
+ return `VCM role hooks cannot reach VCM at ${deps.apiUrl}: ${detail}. Role sessions will not report turns to VCM, so dispatches stay unconfirmed. Check that sh, node, and curl are on PATH and that any HTTP proxy excludes ${readHostname(deps.apiUrl)} through NO_PROXY.`;
207
+ }
208
+ finally {
209
+ pendingProbes.delete(probeToken);
210
+ }
211
+ }
212
+ function runCommand(command, args) {
213
+ return deps.runner.run(command, args, { env: baseEnv, timeoutMs: COMMAND_TIMEOUT_MS });
214
+ }
215
+ }
216
+ function parseVersion(output) {
217
+ const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(output);
218
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] : undefined;
219
+ }
220
+ function compareVersions(left, right) {
221
+ for (let index = 0; index < 3; index += 1) {
222
+ if (left[index] !== right[index]) {
223
+ return left[index] - right[index];
224
+ }
225
+ }
226
+ return 0;
227
+ }
228
+ function formatVersion(version) {
229
+ return version.join(".");
230
+ }
231
+ // Error details end the sentence they are quoted in.
232
+ function firstLine(value) {
233
+ return trimPeriod(value.trim().split(/\r?\n/)[0]?.trim() ?? "");
234
+ }
235
+ function trimPeriod(value) {
236
+ return value.replace(/\.+$/, "");
237
+ }
238
+ function readHostname(url) {
239
+ try {
240
+ return new URL(url).hostname;
241
+ }
242
+ catch {
243
+ return url;
244
+ }
245
+ }
@@ -53,6 +53,17 @@ const LEGACY_CODEX_HARNESS_PATHS = [
53
53
  ".ai/tools/request-codex-review"
54
54
  ];
55
55
  const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
56
+ // The hook self-test runs the installed hook pipeline unchanged except for its
57
+ // endpoint, so a passing probe proves sh, node, curl, PATH, and proxy settings
58
+ // let role hooks reach VCM.
59
+ export const VCM_HOOK_PROBE_PATH = "/api/hooks/probe";
60
+ export function renderVcmHookProbeCommand() {
61
+ const endpoint = '"${VCM_API_URL}/api/hooks/claude-code"';
62
+ if (!VCM_HOOK_COMMAND.includes(endpoint)) {
63
+ throw new Error("VCM hook command no longer posts to /api/hooks/claude-code.");
64
+ }
65
+ return VCM_HOOK_COMMAND.replace(endpoint, () => `"\${VCM_API_URL}${VCM_HOOK_PROBE_PATH}"`);
66
+ }
56
67
  const VCM_STOP_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || true'`;
57
68
  const VCM_PERMISSION_REQUEST_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --max-time 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/permission-request" -H "content-type: application/json" --data-binary @- || true'`;
58
69
  const VCM_BASH_GUARD_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ]; then exit 0; fi; guard=""; repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"; if [ -n "$repo" ] && [ -f "$repo/.ai/tools/vcm-bash-guard" ]; then guard="$repo/.ai/tools/vcm-bash-guard"; else cwd="$(pwd -P 2>/dev/null || pwd)"; dir="$cwd"; while [ -n "$dir" ] && [ "$dir" != "/" ]; do if [ -f "$dir/.ai/tools/vcm-bash-guard" ]; then guard="$dir/.ai/tools/vcm-bash-guard"; break; fi; dir="$(dirname "$dir")"; done; if [ -z "$guard" ] && [ -n "\${CLAUDE_PROJECT_DIR:-}" ] && [ -f "\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard" ]; then guard="\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard"; fi; fi; [ -n "$guard" ] || exit 0; node "$guard" || exit 0'`;
@@ -314,7 +314,19 @@ export function createMessageService(deps) {
314
314
  if (!current || current.acceptedAt) {
315
315
  return;
316
316
  }
317
- await markDispatchConfirmationFailure(input, message.id, DISPATCH_NOT_CONFIRMED_REASON);
317
+ await markDispatchConfirmationFailure(input, message.id, await renderDispatchNotConfirmedReason());
318
+ }
319
+ async function renderDispatchNotConfirmedReason() {
320
+ let hookWarning;
321
+ try {
322
+ hookWarning = await deps.probeHookDelivery?.();
323
+ }
324
+ catch {
325
+ hookWarning = undefined;
326
+ }
327
+ return hookWarning
328
+ ? `${DISPATCH_NOT_CONFIRMED_REASON} The VCM hook self-test also failed, so the role may have run without reporting: ${hookWarning}`
329
+ : DISPATCH_NOT_CONFIRMED_REASON;
318
330
  }
319
331
  async function readMessageById(input, messageId) {
320
332
  return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
@@ -0,0 +1,29 @@
1
+ import { mergeNoProxy } from "./codex-bridge-integration-service.js";
2
+ // Role hooks, harness tools, and usage telemetry post to VCM_API_URL. curl and
3
+ // other HTTP clients send even loopback requests through http_proxy, so a proxy
4
+ // without a NO_PROXY entry for the VCM host would drop every hook silently.
5
+ export function buildVcmNoProxyEnvironment(apiUrl, modelEnvironment = {}, baseEnv = process.env) {
6
+ const host = readUrlHostname(apiUrl);
7
+ if (!host) {
8
+ return {};
9
+ }
10
+ const current = modelEnvironment.NO_PROXY
11
+ ?? modelEnvironment.no_proxy
12
+ ?? baseEnv.NO_PROXY
13
+ ?? baseEnv.no_proxy;
14
+ const noProxy = mergeNoProxy(current, host);
15
+ return { NO_PROXY: noProxy, no_proxy: noProxy };
16
+ }
17
+ function readUrlHostname(value) {
18
+ if (!value) {
19
+ return undefined;
20
+ }
21
+ try {
22
+ const hostname = new URL(value).hostname;
23
+ // URL keeps IPv6 brackets; NO_PROXY lists the bare address.
24
+ return hostname.replace(/^\[(.*)\]$/, "$1") || undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }