@yolo-labs/yolobridge 0.31.0 → 0.32.0

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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Can node-pty spawn ITS OWN helper? On macOS this is what usually failed.
3
+ *
4
+ * ⚠️ NODE-PTY DOES NOT SPAWN YOUR BINARY. On unix it spawns `spawn-helper`
5
+ * and passes the real command as an argument (`pty.cc`: `argv[0] =
6
+ * helper_path`). When that spawn fails it throws the literal string
7
+ * `posix_spawnp failed.` — which names neither the helper nor your agent, and
8
+ * is the same message you get when the agent genuinely is missing.
9
+ *
10
+ * So an operator can have a perfectly good `claude`, pass every check in
11
+ * `resolve-agent-binary.ts`, and still see:
12
+ *
13
+ * yolo-bridge: failed to start the local agent (posix_spawnp failed.)
14
+ *
15
+ * because the thing that could not be spawned was node-pty's own helper. That
16
+ * is exactly what happened on macOS with `claude` installed twice and both
17
+ * copies healthy.
18
+ *
19
+ * ⚠️ macOS ONLY, deliberately. The helper is a darwin build artifact; Linux
20
+ * has no `spawn-helper` on disk at all, so probing for one there would report
21
+ * a missing file on every healthy install — a false alarm in the one direction
22
+ * a diagnostic must never fire.
23
+ *
24
+ * Common causes: npm dropping the executable bit when unpacking, Gatekeeper
25
+ * quarantine on a downloaded prebuild, or a partially-restored node_modules.
26
+ */
27
+ import { createRequire } from 'node:module';
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ const DEFAULT_FS = {
31
+ loadsNative(p) {
32
+ // node-pty has already loaded successfully by the time this runs (the CLI
33
+ // imports it), so for the winning path this is a require-cache hit with no
34
+ // side effects; for a stale or ABI-incompatible one it throws, exactly as
35
+ // it does inside node-pty's own loader.
36
+ try {
37
+ createRequire(import.meta.url)(p);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ },
44
+ existsSync: (p) => fs.existsSync(p),
45
+ statSync: (p) => fs.statSync(p),
46
+ accessSync: (p, mode) => fs.accessSync(p, mode),
47
+ constants: { X_OK: fs.constants.X_OK },
48
+ };
49
+ /** node-pty's package root, or undefined when it cannot be located. */
50
+ export function findNodePtyRoot(resolveFrom, io = DEFAULT_FS) {
51
+ try {
52
+ const require_ = createRequire(resolveFrom);
53
+ let dir = path.dirname(require_.resolve('node-pty'));
54
+ for (let i = 0; i < 8; i++) {
55
+ if (io.existsSync(path.join(dir, 'package.json')) && path.basename(dir) === 'node-pty')
56
+ return dir;
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir)
59
+ break;
60
+ dir = parent;
61
+ }
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ return undefined;
67
+ }
68
+ /**
69
+ * Where node-pty will look for its native module — and therefore its helper.
70
+ *
71
+ * ⚠️ NOT `build/Release`. That was the first version of this check and it was
72
+ * wrong in the worst direction: node-pty 1.1.0 ships PREBUILDS, and
73
+ * `scripts/prebuild.js` only verifies they exist — it never copies them into
74
+ * `build/Release`. So a healthy macOS install loads from
75
+ * `prebuilds/darwin-arm64/` and has no `build/Release` at all, and hardcoding
76
+ * that path would have aborted EVERY attach on EVERY healthy Mac. (codex P1.)
77
+ *
78
+ * This mirrors node-pty's own order from `lib/utils.js` — Release, Debug, then
79
+ * the platform/arch prebuild — and identifies the live directory by the
80
+ * presence of `pty.node`, the same file node-pty itself requires. Both the
81
+ * package root and its `lib/` are checked, matching node-pty's unbundled and
82
+ * bundled relative lookups.
83
+ */
84
+ export function findHelperPath(root, platform, arch, io) {
85
+ return findHelperCandidates(root, platform, arch, io)[0];
86
+ }
87
+ /**
88
+ * EVERY directory node-pty might load from, in its own order.
89
+ *
90
+ * ⚠️ EXISTENCE IS NOT SELECTION, and neither is "any usable helper wins".
91
+ * Two review rounds pushed this in opposite directions, and both were right
92
+ * about the version they saw:
93
+ *
94
+ * · stopping at the first pty.node that merely EXISTS inspects a stale or
95
+ * ABI-incompatible `build/Release` that node-pty skips at runtime — and
96
+ * aborts an attach that works;
97
+ * · accepting ANY usable helper among the candidates passes an install where
98
+ * node-pty loads a Release module whose OWN helper is broken, while a
99
+ * complete prebuild sits unused behind it — and reports health while the
100
+ * spawn fails.
101
+ *
102
+ * Neither is fixable by ordering, because the question is which module
103
+ * actually LOADS. So `loadsNative` mirrors node-pty's require()-and-catch and
104
+ * the winner is the first candidate that genuinely loads; its paired helper is
105
+ * the only one that matters.
106
+ */
107
+ export function findHelperCandidates(root, platform, arch, io) {
108
+ // ⚠️ BUILD TYPE OUTER, BASE INNER — node-pty's `loadNativeModule` tries each
109
+ // build type across BOTH relative bases before advancing to the next type.
110
+ // Inverting these loops picks the root prebuild over a bundled
111
+ // `lib/build/Release` that node-pty would actually load, so the check would
112
+ // inspect a different helper than the one being executed. (codex P2.)
113
+ const dirs = ['build/Release', 'build/Debug', `prebuilds/${platform}-${arch}`];
114
+ const found = [];
115
+ for (const d of dirs) {
116
+ for (const base of [root, path.join(root, 'lib')]) {
117
+ const dir = path.join(base, ...d.split('/'));
118
+ const native = path.join(dir, 'pty.node');
119
+ if (io.existsSync(native) && io.loadsNative(native))
120
+ found.push(path.join(dir, 'spawn-helper'));
121
+ }
122
+ }
123
+ return found;
124
+ }
125
+ /** Whether one candidate helper is a real, executable file. */
126
+ function helperUsable(helperPath, io) {
127
+ if (!io.existsSync(helperPath))
128
+ return false;
129
+ try {
130
+ if (!io.statSync(helperPath).isFile())
131
+ return false;
132
+ io.accessSync(helperPath, io.constants.X_OK);
133
+ return true;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
139
+ /**
140
+ * Whether node-pty's spawn-helper is present and executable.
141
+ *
142
+ * Returns `not-applicable` off darwin and whenever the helper cannot be
143
+ * located — an inconclusive probe must not manufacture a failure, because the
144
+ * cost of a false alarm here is sending someone to chmod a file that was never
145
+ * meant to exist.
146
+ */
147
+ export function checkPtyHelper(opts = {}) {
148
+ const platform = opts.platform ?? process.platform;
149
+ if (platform !== 'darwin')
150
+ return { ok: true, reason: 'not-applicable' };
151
+ const io = opts.io ?? DEFAULT_FS;
152
+ const root = opts.nodePtyRoot ?? findNodePtyRoot(opts.resolveFrom ?? import.meta.url, io);
153
+ if (!root)
154
+ return { ok: true, reason: 'not-applicable' };
155
+ const candidates = findHelperCandidates(root, platform, opts.arch ?? process.arch, io);
156
+ // Could not tell where node-pty loads from: stay silent rather than invent a
157
+ // fault. Same rule as an unlocatable package.
158
+ if (candidates.length === 0)
159
+ return { ok: true, reason: 'not-applicable' };
160
+ // ⚠️ THE FIRST CANDIDATE THAT ACTUALLY LOADS IS THE ONE NODE-PTY USES, and
161
+ // only ITS helper matters. A complete prebuild sitting behind a loadable
162
+ // Release module is never reached, so it cannot excuse a broken helper
163
+ // there. (codex P2, correcting the previous round's over-correction.)
164
+ const helperPath = candidates[0];
165
+ if (helperUsable(helperPath, io))
166
+ return { ok: true, reason: 'healthy' };
167
+ if (!io.existsSync(helperPath)) {
168
+ return {
169
+ ok: false,
170
+ reason: 'missing',
171
+ helperPath,
172
+ message: `node-pty's spawn-helper is missing at ${helperPath}.\n`
173
+ + ` ⚠️ This is NOT a problem with your agent. node-pty spawns this helper rather than\n`
174
+ + ` your binary directly, so its absence surfaces as the same "posix_spawnp failed."\n`
175
+ + ` you would see if the agent itself were missing.\n`
176
+ + ` Reinstall to restore it: npm i -g @yolo-labs/yolobridge`,
177
+ };
178
+ }
179
+ // ⚠️ A DIRECTORY PASSES X_OK. `accessSync(dir, X_OK)` succeeds for any
180
+ // searchable directory, so a corrupt or partially-restored install with a
181
+ // directory at this path would be waved through as healthy while node-pty
182
+ // cannot execute it. (codex P2.)
183
+ let isFile;
184
+ try {
185
+ isFile = io.statSync(helperPath).isFile();
186
+ }
187
+ catch {
188
+ isFile = false;
189
+ }
190
+ if (!isFile) {
191
+ return {
192
+ ok: false,
193
+ reason: 'missing',
194
+ helperPath,
195
+ message: `node-pty's spawn-helper at ${helperPath} is not a file.\n`
196
+ + ` ⚠️ This is NOT a problem with your agent — node-pty spawns this helper rather than\n`
197
+ + ` your binary directly, so it fails with the same "posix_spawnp failed."\n`
198
+ + ` Reinstall to restore it: npm i -g @yolo-labs/yolobridge`,
199
+ };
200
+ }
201
+ try {
202
+ io.accessSync(helperPath, io.constants.X_OK);
203
+ }
204
+ catch {
205
+ return {
206
+ ok: false,
207
+ reason: 'not-executable',
208
+ helperPath,
209
+ message: `node-pty's spawn-helper at ${helperPath} is not executable.\n`
210
+ + ` ⚠️ This is NOT a problem with your agent. node-pty spawns this helper rather than\n`
211
+ + ` your binary directly, so it fails with the same "posix_spawnp failed." you would\n`
212
+ + ` see if the agent itself were missing. npm can drop the executable bit on unpack.\n`
213
+ + ` Fix it with: chmod +x ${helperPath}`,
214
+ };
215
+ }
216
+ return { ok: true, reason: 'healthy' };
217
+ }
package/dist/cli.js CHANGED
@@ -36,6 +36,7 @@ import { runAllow } from './approved-paths.js';
36
36
  import { runDetach } from './detach-cmd.js';
37
37
  import { getStatus, formatStatus } from './status-cmd.js';
38
38
  import { resolveAgentBinary } from './resolve-agent-binary.js';
39
+ import { checkPtyHelper } from './check-pty-helper.js';
39
40
  import { startLocalAgent, stopLocalAgent, DEFAULT_AGENT_BIN } from './local-agent.js';
40
41
  import { runListWorkspaces, formatWorkspacesTable } from './workspaces-cmd.js';
41
42
  import { startMcpProxy, mcpUrl, SECRET_ENV_VAR } from './mcp-proxy.js';
@@ -466,6 +467,22 @@ async function cmdAttach(args) {
466
467
  // Say where Ctrl+C goes BEFORE the agent takes over the screen.
467
468
  // Without this the operator presses it expecting to quit, nothing
468
469
  // happens, and there is no way to discover why.
470
+ // ⚠️ THE HELPER FIRST, BEFORE THE AGENT. node-pty does not spawn
471
+ // your binary — on unix it spawns its own `spawn-helper` and passes
472
+ // the real command as an argument (`pty.cc`: argv[0] = helper_path).
473
+ // A broken helper therefore fails with the SAME
474
+ // `posix_spawnp failed.` as a missing agent, and checking the agent
475
+ // first would report it healthy and leave the operator debugging a
476
+ // binary that was never at fault — which is exactly what happened on
477
+ // macOS with two working `claude` installs on PATH.
478
+ const helper = checkPtyHelper();
479
+ if (!helper.ok) {
480
+ process.stdout.write(`yolo-bridge: cannot start the agent — ${helper.message}\n`);
481
+ process.stdout.write('yolo-bridge: detaching...\n');
482
+ stopRequested = true;
483
+ await runDetach({ commonApiBaseUrl: apiUrl() }).catch(() => undefined);
484
+ return;
485
+ }
469
486
  // ⚠️ RESOLVE THE BINARY BEFORE SPAWNING IT, so a failure can be
470
487
  // EXPLAINED. node-pty reports an unspawnable binary as the bare
471
488
  // string `posix_spawnp failed.` — no binary name, no PATH, no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "YoloBridge \u2014 local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
5
5
  "license": "MIT",
6
6
  "type": "module",