@wenrwa/marketplace-cli 0.8.1 → 0.8.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /** oc-25 — the bridge provides OpenCode (resolution order, shared install, failure text). */
4
+ const opencodeBinary_1 = require("../bridge/opencodeBinary");
5
+ const opencodeConfig_1 = require("../bridge/opencodeConfig");
6
+ const BASE = '/h/.wen-market/opencode';
7
+ const PREFIX = (0, opencodeBinary_1.managedPrefix)(BASE);
8
+ const BIN = (0, opencodeBinary_1.managedBin)(PREFIX);
9
+ const launch = { command: 'opencode', args: ['-s', 'ses_a'], env: { ...opencodeConfig_1.OPENCODE_QUIET_ENV, X: '1' } };
10
+ function harness(over = {}) {
11
+ const files = new Map();
12
+ const npmCalls = [];
13
+ let release;
14
+ const gate = new Promise((r) => { release = r; });
15
+ const deps = {
16
+ exists: (p) => files.has(p),
17
+ writeFile: (p, c) => { files.set(p, c); },
18
+ mkdirp: () => { },
19
+ rm: (p) => { files.delete(p); },
20
+ runNpm: async (args, env) => {
21
+ npmCalls.push({ args, env });
22
+ await gate;
23
+ if ((over.npmCode ?? 0) === 0)
24
+ files.set(BIN, 'bin');
25
+ return { code: over.npmCode ?? 0, stderr: '' };
26
+ },
27
+ pathOpencode: () => null,
28
+ versionOf: () => opencodeConfig_1.OPENCODE_PINNED_VERSION,
29
+ env: { PATH: '/usr/bin', npm_config_offline: 'true' },
30
+ platform: 'darwin',
31
+ ...over,
32
+ };
33
+ return { files, npmCalls, release, deps };
34
+ }
35
+ describe('oc-25 — pinned version lives in one place', () => {
36
+ it('the minimum IS the pinned version', () => {
37
+ expect(opencodeConfig_1.OPENCODE_MIN_VERSION).toBe(opencodeConfig_1.OPENCODE_PINNED_VERSION);
38
+ });
39
+ });
40
+ describe('oc-25 — resolution order', () => {
41
+ it('1. managed pinned binary wins over a newer PATH one; absolute path, no install', () => {
42
+ const h = harness({ pathOpencode: () => ({ path: '/usr/local/bin/opencode', version: '9.9.9' }) });
43
+ h.files.set(BIN, 'x');
44
+ h.files.set(`${PREFIX}/.ready`, '1');
45
+ const r = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps).prepare(launch);
46
+ expect(r).toEqual({ ok: true, launch: { ...launch, command: BIN } });
47
+ expect(h.npmCalls).toHaveLength(0);
48
+ });
49
+ it('2. PATH opencode at/above the minimum is used by absolute path', () => {
50
+ const h = harness({ pathOpencode: () => ({ path: '/opt/bin/opencode', version: '1.20.0' }) });
51
+ const r = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps).prepare(launch);
52
+ expect(r).toEqual({ ok: true, launch: { ...launch, command: '/opt/bin/opencode' } });
53
+ expect(h.npmCalls).toHaveLength(0);
54
+ });
55
+ it('3. a PATH opencode below the minimum is ignored and the pinned one is installed', async () => {
56
+ const h = harness({ pathOpencode: () => ({ path: '/opt/bin/opencode', version: '1.2.0' }) });
57
+ const p = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps);
58
+ const r = p.prepare(launch);
59
+ expect(h.npmCalls).toHaveLength(1);
60
+ expect(r.ok && r.launch.command).toBe('/bin/sh');
61
+ h.release();
62
+ await p.settled();
63
+ });
64
+ });
65
+ describe('oc-25 — install', () => {
66
+ it('installs the pinned version into the bridge prefix with npm_config_offline scrubbed', async () => {
67
+ const h = harness();
68
+ const p = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps);
69
+ const r = p.prepare(launch);
70
+ expect(h.npmCalls[0].args).toEqual(['install', '--prefix', PREFIX, `opencode-ai@${opencodeConfig_1.OPENCODE_PINNED_VERSION}`, '--no-audit', '--no-fund']);
71
+ expect(h.npmCalls[0].env.npm_config_offline).toBeUndefined();
72
+ expect(h.npmCalls[0].env.PATH).toBe('/usr/bin');
73
+ // the pane runs a waiter that execs the ABSOLUTE managed binary with the original args
74
+ expect(r.ok && r.launch.args).toEqual(['-c', opencodeBinary_1.OPENCODE_WAIT_SCRIPT, 'opencode-wait', `${PREFIX}/.ready`, `${PREFIX}/.install-failed`, BIN, '-s', 'ses_a']);
75
+ expect(r.ok && r.launch.env).toEqual(launch.env);
76
+ expect(h.files.has(`${PREFIX}/.ready`)).toBe(false);
77
+ h.release();
78
+ await p.settled();
79
+ expect(h.files.has(`${PREFIX}/.ready`)).toBe(true);
80
+ // and the next open uses it directly
81
+ expect(p.prepare(launch)).toEqual({ ok: true, launch: { ...launch, command: BIN } });
82
+ expect(h.npmCalls).toHaveLength(1);
83
+ });
84
+ it('two panes opening at once share ONE install', async () => {
85
+ const h = harness();
86
+ const p = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps);
87
+ p.prepare(launch);
88
+ p.prepare(launch);
89
+ expect(h.npmCalls).toHaveLength(1);
90
+ h.release();
91
+ await p.settled();
92
+ });
93
+ it('failure writes a short wrapped message with the manual command, no ready marker; a later open retries', async () => {
94
+ const h = harness({ npmCode: 1 });
95
+ const p = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps);
96
+ p.prepare(launch);
97
+ h.release();
98
+ await p.settled();
99
+ const msg = h.files.get(`${PREFIX}/.install-failed`);
100
+ expect(msg).toContain(`npm i -g opencode-ai@${opencodeConfig_1.OPENCODE_PINNED_VERSION}`);
101
+ for (const line of msg.split('\n'))
102
+ expect(line.length).toBeLessThanOrEqual(40);
103
+ expect(h.files.has(`${PREFIX}/.ready`)).toBe(false);
104
+ p.prepare(launch);
105
+ expect(h.npmCalls).toHaveLength(2);
106
+ expect(h.files.has(`${PREFIX}/.install-failed`)).toBe(false); // stale verdict cleared
107
+ h.release();
108
+ await p.settled();
109
+ });
110
+ it('a thrown npm spawn error is a failure, not a crash', async () => {
111
+ const h = harness({ runNpm: async () => { throw new Error('spawn npm ENOENT'); } });
112
+ const p = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps);
113
+ p.prepare(launch);
114
+ await p.settled();
115
+ expect(h.files.get(`${PREFIX}/.install-failed`)).toContain('spawn npm ENOENT');
116
+ });
117
+ it('refuses with the manual command on win32 (no sh waiter)', () => {
118
+ const h = harness({ platform: 'win32' });
119
+ const r = (0, opencodeBinary_1.createOpencodeProvisioner)(BASE, h.deps).prepare(launch);
120
+ expect(r.ok).toBe(false);
121
+ expect(!r.ok && r.message).toContain(`npm i -g opencode-ai@${opencodeConfig_1.OPENCODE_PINNED_VERSION}`);
122
+ expect(h.npmCalls).toHaveLength(0);
123
+ });
124
+ it('failure lines are short', () => {
125
+ for (const l of (0, opencodeBinary_1.opencodeInstallFailureLines)('x'.repeat(200)))
126
+ expect(l.length).toBeLessThanOrEqual(65);
127
+ });
128
+ });
@@ -177,3 +177,40 @@ describe('relay lane carries the server-delivered opencode block', () => {
177
177
  await new Promise((r) => wss.close(r));
178
178
  });
179
179
  });
180
+ describe('oc-25 — relay lane spawns what the provisioner prepared', () => {
181
+ it('swaps in the absolute binary; a refusal becomes the typed error with its own message', async () => {
182
+ const { wss, port, ptys, factory } = await setup();
183
+ const frames = [];
184
+ let conn;
185
+ const got = new Promise((r) => wss.on('connection', (ws) => { conn = ws; ws.on('message', (m) => frames.push(JSON.parse(String(m)))); r(); }));
186
+ let refuse = false;
187
+ const handle = (0, relayClient_1.startRelayClient)({
188
+ relayUrl: `ws://127.0.0.1:${port}`, relayToken: 'tok',
189
+ sessions: new sessionManager_1.SessionManager(factory),
190
+ resolveLaunch: (o) => (0, agentLaunch_1.resolveAgentLaunch)({ autonomous: true, opencode: o.opencode, connectToken: o.connectToken }),
191
+ commandExists: () => true,
192
+ prepareOpencodeLaunch: (l) => refuse
193
+ ? { ok: false, message: 'short refusal' }
194
+ : { ok: true, launch: { ...l, command: '/h/.wen-market/opencode/1.18.35/node_modules/.bin/opencode' } },
195
+ });
196
+ await got;
197
+ const wait = async (pred) => { for (let i = 0; i < 100; i++) {
198
+ const f = frames.find(pred);
199
+ if (f)
200
+ return f;
201
+ await new Promise((r) => setTimeout(r, 10));
202
+ } throw new Error('timeout'); };
203
+ conn.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'b-1', workspaceId: 'w', opencode: oc() }));
204
+ await wait((f) => f.t === 'opened');
205
+ expect(ptys[0].opts.command).toBe('/h/.wen-market/opencode/1.18.35/node_modules/.bin/opencode');
206
+ refuse = true;
207
+ conn.send(JSON.stringify({ t: 'open', cols: 80, rows: 24, sessionId: 'b-2', workspaceId: 'w', opencode: oc() }));
208
+ const err = await wait((f) => f.t === 'error' && f.sessionId === 'b-2');
209
+ expect(err.message).toBe('short refusal');
210
+ expect(ptys).toHaveLength(1);
211
+ handle.close();
212
+ for (const c of wss.clients)
213
+ c.terminate();
214
+ await new Promise((r) => wss.close(r));
215
+ });
216
+ });
@@ -0,0 +1,47 @@
1
+ export declare const OPENCODE_PACKAGE = "opencode-ai";
2
+ export declare const OPENCODE_INSTALL_TIMEOUT_MS: number;
3
+ export interface OpencodeBinaryDeps {
4
+ exists: (p: string) => boolean;
5
+ writeFile: (p: string, content: string) => void;
6
+ mkdirp: (dir: string) => void;
7
+ rm: (p: string) => void;
8
+ /** Run npm with exactly this env. Resolves { code } (never rejects on non-zero). */
9
+ runNpm: (args: string[], env: NodeJS.ProcessEnv) => Promise<{
10
+ code: number;
11
+ stderr?: string;
12
+ }>;
13
+ /** PATH lookup: absolute path + `--version` output of the `opencode` on PATH, or null. */
14
+ pathOpencode: () => {
15
+ path: string;
16
+ version: string | null;
17
+ } | null;
18
+ /** `<bin> --version` output, or null if it will not run. */
19
+ versionOf: (bin: string) => string | null;
20
+ env: NodeJS.ProcessEnv;
21
+ platform: NodeJS.Platform;
22
+ }
23
+ export interface LaunchShape {
24
+ command: string;
25
+ args: string[];
26
+ env: Record<string, string>;
27
+ }
28
+ export type PreparedLaunch = {
29
+ ok: true;
30
+ launch: LaunchShape;
31
+ } | {
32
+ ok: false;
33
+ message: string;
34
+ };
35
+ export declare function managedPrefix(baseDir: string, version?: string): string;
36
+ export declare const managedBin: (prefix: string) => string;
37
+ /** Short lines on purpose: a narrow pane wraps long ones badly. */
38
+ export declare function opencodeInstallFailureLines(reason: string): string[];
39
+ /** POSIX sh waiter: progress now, then exec once the bridge marks the install ready (or print the failure). */
40
+ export declare const OPENCODE_WAIT_SCRIPT: string;
41
+ export declare function createOpencodeProvisioner(baseDir: string, deps: OpencodeBinaryDeps): {
42
+ managedBin: string;
43
+ /** Resolve (or start provisioning) and return the launch to spawn. Synchronous. */
44
+ prepare(launch: LaunchShape): PreparedLaunch;
45
+ /** Test seam: resolves when the in-flight install (if any) settles. */
46
+ settled: () => Promise<void>;
47
+ };
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.OPENCODE_WAIT_SCRIPT = exports.managedBin = exports.OPENCODE_INSTALL_TIMEOUT_MS = exports.OPENCODE_PACKAGE = void 0;
37
+ exports.managedPrefix = managedPrefix;
38
+ exports.opencodeInstallFailureLines = opencodeInstallFailureLines;
39
+ exports.createOpencodeProvisioner = createOpencodeProvisioner;
40
+ /**
41
+ * oc-25 — the bridge provides OpenCode itself (ADR-0013 local-lane parity).
42
+ *
43
+ * A user must never install OpenCode by hand. Binary choice, in order:
44
+ * 1. the bridge-managed pinned copy (~/.wen-market/opencode/<pinned>), if there;
45
+ * 2. else a PATH `opencode` at or above the minimum;
46
+ * 3. else install the pinned version into the managed prefix (lazy: only on
47
+ * the first OpenCode launch, so Claude-only users never download it).
48
+ *
49
+ * The launch always carries an ABSOLUTE binary path. While an install runs the
50
+ * PTY runs a tiny `sh` waiter (progress line, then exec) — the pane shows
51
+ * "Setting up OpenCode" instead of a blank wait or a bare shell. Concurrent
52
+ * opens share ONE install (in-flight promise). The install must NOT inherit
53
+ * OPENCODE_QUIET_ENV's npm_config_offline=true, so it runs with a scrubbed env.
54
+ */
55
+ const path = __importStar(require("path"));
56
+ const opencodeConfig_1 = require("./opencodeConfig");
57
+ exports.OPENCODE_PACKAGE = 'opencode-ai';
58
+ exports.OPENCODE_INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
59
+ function managedPrefix(baseDir, version = opencodeConfig_1.OPENCODE_PINNED_VERSION) {
60
+ return path.join(baseDir, version);
61
+ }
62
+ const managedBin = (prefix) => path.join(prefix, 'node_modules', '.bin', 'opencode');
63
+ exports.managedBin = managedBin;
64
+ const readyMarker = (prefix) => path.join(prefix, '.ready');
65
+ const failMarker = (prefix) => path.join(prefix, '.install-failed');
66
+ /** Short lines on purpose: a narrow pane wraps long ones badly. */
67
+ function opencodeInstallFailureLines(reason) {
68
+ const why = reason.replace(/\s+/g, ' ').trim().slice(0, 60);
69
+ return [
70
+ 'Could not set up OpenCode.',
71
+ ...(why ? [why] : []),
72
+ 'Install it by hand:',
73
+ `npm i -g ${exports.OPENCODE_PACKAGE}@${opencodeConfig_1.OPENCODE_PINNED_VERSION}`,
74
+ 'Then reopen this pane.',
75
+ ];
76
+ }
77
+ /** POSIX sh waiter: progress now, then exec once the bridge marks the install ready (or print the failure). */
78
+ exports.OPENCODE_WAIT_SCRIPT = [
79
+ 'ready=$1; fail=$2; shift 2',
80
+ "printf '\\r\\n\\033[2mSetting up OpenCode (one-time, ~30s)...\\033[0m\\r\\n'",
81
+ 'while [ ! -e "$ready" ]; do',
82
+ ' if [ -e "$fail" ]; then',
83
+ " printf '\\r\\n'",
84
+ " while IFS= read -r l; do printf '\\033[31m%s\\033[0m\\r\\n' \"$l\"; done < \"$fail\"",
85
+ ' exit 1',
86
+ ' fi',
87
+ ' sleep 1',
88
+ 'done',
89
+ 'exec "$@"',
90
+ ].join('\n');
91
+ function createOpencodeProvisioner(baseDir, deps) {
92
+ const prefix = managedPrefix(baseDir);
93
+ const bin = (0, exports.managedBin)(prefix);
94
+ let inflight = null;
95
+ function install() {
96
+ if (inflight)
97
+ return inflight;
98
+ // Clear the previous verdict synchronously so a waiter spawned right after never reads a stale one.
99
+ try {
100
+ deps.rm(failMarker(prefix));
101
+ }
102
+ catch { /* none */ }
103
+ const env = { ...deps.env };
104
+ for (const k of Object.keys(env))
105
+ if (k.toLowerCase() === 'npm_config_offline')
106
+ delete env[k];
107
+ const fail = (reason) => {
108
+ try {
109
+ deps.writeFile(failMarker(prefix), opencodeInstallFailureLines(reason).join('\n') + '\n');
110
+ }
111
+ catch { /* best effort */ }
112
+ };
113
+ const run = (async () => {
114
+ try {
115
+ deps.mkdirp(prefix);
116
+ const pkg = path.join(prefix, 'package.json');
117
+ if (!deps.exists(pkg))
118
+ deps.writeFile(pkg, JSON.stringify({ name: 'wen-market-opencode', private: true }) + '\n');
119
+ const res = await deps.runNpm(['install', '--prefix', prefix, `${exports.OPENCODE_PACKAGE}@${opencodeConfig_1.OPENCODE_PINNED_VERSION}`, '--no-audit', '--no-fund'], env);
120
+ if (res.code !== 0)
121
+ return fail(`npm exited ${res.code}`);
122
+ if (!deps.exists(bin) || !(0, opencodeConfig_1.opencodeVersionOk)(deps.versionOf(bin)))
123
+ return fail('installed binary will not run');
124
+ deps.writeFile(readyMarker(prefix), opencodeConfig_1.OPENCODE_PINNED_VERSION);
125
+ }
126
+ catch (err) {
127
+ fail(err instanceof Error ? err.message : String(err));
128
+ }
129
+ })();
130
+ inflight = run;
131
+ void run.then(() => { if (inflight === run)
132
+ inflight = null; });
133
+ return run;
134
+ }
135
+ return {
136
+ managedBin: bin,
137
+ /** Resolve (or start provisioning) and return the launch to spawn. Synchronous. */
138
+ prepare(launch) {
139
+ const withBin = (b) => ({ ...launch, command: b });
140
+ if (deps.exists(bin) && deps.exists(readyMarker(prefix)))
141
+ return { ok: true, launch: withBin(bin) };
142
+ const onPath = deps.pathOpencode();
143
+ if (onPath && (0, opencodeConfig_1.opencodeVersionOk)(onPath.version))
144
+ return { ok: true, launch: withBin(onPath.path) };
145
+ if (deps.platform === 'win32') {
146
+ return { ok: false, message: opencodeInstallFailureLines('automatic setup is not on Windows yet').join('\r\n') };
147
+ }
148
+ void install();
149
+ return {
150
+ ok: true,
151
+ launch: {
152
+ ...launch,
153
+ command: '/bin/sh',
154
+ args: ['-c', exports.OPENCODE_WAIT_SCRIPT, 'opencode-wait', readyMarker(prefix), failMarker(prefix), bin, ...launch.args],
155
+ },
156
+ };
157
+ },
158
+ /** Test seam: resolves when the in-flight install (if any) settles. */
159
+ settled: () => inflight ?? Promise.resolve(),
160
+ };
161
+ }
@@ -10,6 +10,8 @@
10
10
  */
11
11
  /** Oldest OpenCode the local lane accepts. 1.18.35 is the version oc-04 verified
12
12
  * (`permission.asked` / `session.idle` events, `-s <id>` resume, quiet env). */
13
+ export declare const OPENCODE_PINNED_VERSION = "1.18.35";
14
+ /** oc-25: the version the bridge installs for itself IS the minimum it accepts from PATH. */
13
15
  export declare const OPENCODE_MIN_VERSION = "1.18.35";
14
16
  export declare const OPENCODE_INSTALL_HINT = "Install OpenCode: npm i -g opencode-ai@>=1.18.35";
15
17
  /** Every outbound call OpenCode makes at startup besides the gateway (oc-04 s5). */
@@ -10,12 +10,14 @@
10
10
  * (`{env:HIVEOP_GATEWAY_TOKEN}`) so it never appears inside the JSON.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.isCapturedOpencodeSessionId = exports.OPENCODE_GATEWAY_TOKEN_ENV = exports.OPENCODE_PROVIDER_ID = exports.OPENCODE_QUIET_ENV = exports.OPENCODE_INSTALL_HINT = exports.OPENCODE_MIN_VERSION = void 0;
13
+ exports.isCapturedOpencodeSessionId = exports.OPENCODE_GATEWAY_TOKEN_ENV = exports.OPENCODE_PROVIDER_ID = exports.OPENCODE_QUIET_ENV = exports.OPENCODE_INSTALL_HINT = exports.OPENCODE_MIN_VERSION = exports.OPENCODE_PINNED_VERSION = void 0;
14
14
  exports.buildOpencodeConfig = buildOpencodeConfig;
15
15
  exports.opencodeVersionOk = opencodeVersionOk;
16
16
  /** Oldest OpenCode the local lane accepts. 1.18.35 is the version oc-04 verified
17
17
  * (`permission.asked` / `session.idle` events, `-s <id>` resume, quiet env). */
18
- exports.OPENCODE_MIN_VERSION = '1.18.35';
18
+ exports.OPENCODE_PINNED_VERSION = '1.18.35';
19
+ /** oc-25: the version the bridge installs for itself IS the minimum it accepts from PATH. */
20
+ exports.OPENCODE_MIN_VERSION = exports.OPENCODE_PINNED_VERSION;
19
21
  exports.OPENCODE_INSTALL_HINT = `Install OpenCode: npm i -g opencode-ai@>=${exports.OPENCODE_MIN_VERSION}`;
20
22
  /** Every outbound call OpenCode makes at startup besides the gateway (oc-04 s5). */
21
23
  exports.OPENCODE_QUIET_ENV = Object.freeze({
@@ -17,6 +17,7 @@
17
17
  * `replay`, exactly as it would against the local server.
18
18
  */
19
19
  import { type OpencodeLaunchInput } from './agentLaunch';
20
+ import type { PreparedLaunch } from './opencodeBinary';
20
21
  import { SessionManager } from './sessionManager';
21
22
  import { MemInfo } from './containerMemory';
22
23
  import { BrowserDriver } from './browserDriver';
@@ -200,6 +201,12 @@ export interface RelayClientOpts {
200
201
  * error frame the conductor can report.
201
202
  */
202
203
  commandExists?: (command: string) => boolean;
204
+ /** oc-25 — see BridgeServerOpts#prepareOpencodeLaunch. */
205
+ prepareOpencodeLaunch?: (launch: {
206
+ command: string;
207
+ args: string[];
208
+ env: Record<string, string>;
209
+ }) => PreparedLaunch;
203
210
  /**
204
211
  * fcct-27 — RELAY-OWNED SESSIONS ONLY. The consent boundary, made structural.
205
212
  *
@@ -1060,9 +1060,17 @@ function startRelayClient(opts) {
1060
1060
  // no override, so realPty spawns $SHELL and autolaunch.sh turns it
1061
1061
  // into claude. Present (laptop) → `claude` explicitly, since
1062
1062
  // nothing on a user's machine does that for us.
1063
- const launch = opts.resolveLaunch?.({ workspaceId, sessionId: relaySessionId, autonomous, opencode, connectToken }) ?? null;
1064
- if (launch && opts.commandExists && !opts.commandExists(launch.command)) {
1065
- const message = (0, agentLaunch_1.missingCommandMessage)(launch.command);
1063
+ let launch = opts.resolveLaunch?.({ workspaceId, sessionId: relaySessionId, autonomous, opencode, connectToken }) ?? null;
1064
+ let refusal = null;
1065
+ if (launch && launch.command === 'opencode' && opts.prepareOpencodeLaunch) {
1066
+ const prepared = opts.prepareOpencodeLaunch({ ...launch, env: launch.env ?? {} });
1067
+ if (prepared.ok)
1068
+ launch = prepared.launch;
1069
+ else
1070
+ refusal = prepared.message;
1071
+ }
1072
+ if (launch && (refusal || (opts.commandExists && !opts.commandExists(launch.command)))) {
1073
+ const message = refusal ?? (0, agentLaunch_1.missingCommandMessage)(launch.command);
1066
1074
  console.error(`[bridge] refusing PTY spawn — ${message}`);
1067
1075
  send(socket, {
1068
1076
  t: 'error',
@@ -2,6 +2,7 @@ import { SessionManager } from './sessionManager';
2
2
  import { ValidatePathResult } from './localPathValidation';
3
3
  import { PickFolderResult } from './folderPicker';
4
4
  import { LocalAddCoreResult } from './localAdd';
5
+ import type { PreparedLaunch } from './opencodeBinary';
5
6
  import { VerifyOriginResult } from './originVerification';
6
7
  import { PublishResult } from './localPublish';
7
8
  import { PublishPreflightResult } from './publishPreflight';
@@ -108,6 +109,14 @@ export interface BridgeServerOpts {
108
109
  * crashes the open — see the 'open' handler below.
109
110
  */
110
111
  commandExists?: (command: string) => boolean;
112
+ /** oc-25 — resolves/provisions OpenCode for an `opencode` launch: returns the
113
+ * launch to spawn (absolute binary, or a progress waiter while the bridge
114
+ * installs the pinned copy) or a refusal message. Absent → bare `opencode`. */
115
+ prepareOpencodeLaunch?: (launch: {
116
+ command: string;
117
+ args: string[];
118
+ env: Record<string, string>;
119
+ }) => PreparedLaunch;
111
120
  /**
112
121
  * psp-01 (production bug, 2026-09-14; supersedes bres-01's `hasSessionHistory`)
113
122
  * — does Claude Code already have a conversation on disk for THIS AGENT at
@@ -733,7 +733,7 @@ function startBridgeServer(opts) {
733
733
  // folder still falls back to $HOME for its PTY cwd, but the
734
734
  // workgraph MCP pin should still match whatever workspace the
735
735
  // arena client actually asked to open.
736
- const launch = (0, agentLaunch_1.resolveAgentLaunch)({
736
+ let launch = (0, agentLaunch_1.resolveAgentLaunch)({
737
737
  autonomous: !!frame.autonomous,
738
738
  ...(frame.opencode ? { opencode: frame.opencode } : {}),
739
739
  ...(frame.connectToken ? { connectToken: frame.connectToken } : {}),
@@ -745,7 +745,15 @@ function startBridgeServer(opts) {
745
745
  : {}),
746
746
  ...(frame.workspaceId ? { workspaceId: frame.workspaceId } : {}),
747
747
  });
748
- const claudeAvailable = opts.commandExists ? opts.commandExists(launch.command) : true;
748
+ let refusal = null;
749
+ if (launch.command === 'opencode' && opts.prepareOpencodeLaunch) {
750
+ const prepared = opts.prepareOpencodeLaunch(launch);
751
+ if (prepared.ok)
752
+ launch = prepared.launch;
753
+ else
754
+ refusal = prepared.message;
755
+ }
756
+ const claudeAvailable = !refusal && (opts.commandExists ? opts.commandExists(launch.command) : true);
749
757
  if (!claudeAvailable) {
750
758
  // Never crash on a missing binary: open a plain session (no
751
759
  // command override — realPty's $SHELL default) so the pane
@@ -771,9 +779,11 @@ function startBridgeServer(opts) {
771
779
  send(ws, {
772
780
  t: 'data',
773
781
  sessionId,
774
- data: launch.command === 'opencode'
775
- ? `\r\n\x1b[31m${(0, agentLaunch_1.missingCommandMessage)('opencode')}, then reopen this pane.\x1b[0m\r\n`
776
- : '\r\n\x1b[31mclaude not found on PATH — install Claude Code CLI, then reopen this pane.\x1b[0m\r\n',
782
+ data: refusal
783
+ ? `\r\n\x1b[31m${refusal}\x1b[0m\r\n`
784
+ : launch.command === 'opencode'
785
+ ? `\r\n\x1b[31m${(0, agentLaunch_1.missingCommandMessage)('opencode')}, then reopen this pane.\x1b[0m\r\n`
786
+ : '\r\n\x1b[31mclaude not found on PATH — install Claude Code CLI, then reopen this pane.\x1b[0m\r\n',
777
787
  });
778
788
  }).catch((err) => {
779
789
  console.error('[bridge] session attach failed:', err);
@@ -70,6 +70,7 @@ const realPty_1 = require("../bridge/realPty");
70
70
  const config_1 = require("../bridge/config");
71
71
  const mcpRegister_1 = require("../bridge/mcpRegister");
72
72
  const mcpInstall_1 = require("../bridge/mcpInstall");
73
+ const opencodeBinary_1 = require("../bridge/opencodeBinary");
73
74
  const autostart_1 = require("../bridge/autostart");
74
75
  const repoCheckout_1 = require("../bridge/repoCheckout");
75
76
  const bundleInstaller_1 = require("../bridge/bundleInstaller");
@@ -192,6 +193,43 @@ function opencodeUsable() {
192
193
  * pane). One implementation so the two lanes can never disagree about whether
193
194
  * this machine can run Claude Code.
194
195
  */
196
+ /**
197
+ * oc-25 — ONE provisioner for the process, so the loopback lane and the crew
198
+ * relay lane (and every pane) share a single in-flight OpenCode install.
199
+ */
200
+ const opencodeProvisioner = (0, opencodeBinary_1.createOpencodeProvisioner)(path.join(os.homedir(), '.wen-market', 'opencode'), {
201
+ exists: (p) => fs.existsSync(p),
202
+ writeFile: (p, c) => fs.writeFileSync(p, c),
203
+ mkdirp: (d) => fs.mkdirSync(d, { recursive: true }),
204
+ rm: (p) => fs.rmSync(p, { force: true }),
205
+ runNpm: (args, env) => new Promise((resolve) => {
206
+ (0, child_process_1.execFile)(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { env, timeout: opencodeBinary_1.OPENCODE_INSTALL_TIMEOUT_MS }, (err, _out, stderr) => {
207
+ resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stderr: String(stderr ?? '') });
208
+ });
209
+ }),
210
+ pathOpencode: () => {
211
+ try {
212
+ const abs = (0, child_process_1.execFileSync)(process.platform === 'win32' ? 'where' : 'command', process.platform === 'win32' ? ['opencode'] : ['-v', 'opencode'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform !== 'win32' }).split(/\r?\n/)[0].trim();
213
+ if (!abs)
214
+ return null;
215
+ return { path: abs, version: opencodeVersionOf(abs) };
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ },
221
+ versionOf: (bin) => opencodeVersionOf(bin),
222
+ env: process.env,
223
+ platform: process.platform,
224
+ });
225
+ function opencodeVersionOf(bin) {
226
+ try {
227
+ return (0, child_process_1.execFileSync)(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 });
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ }
195
233
  function commandExistsOnPath(command) {
196
234
  if (command === 'opencode')
197
235
  return opencodeUsable();
@@ -1210,6 +1248,7 @@ exports.bridgeCommand = new commander_1.Command('bridge')
1210
1248
  // agent's PTY, so a missing install surfaces a clear in-pane error
1211
1249
  // line (server.ts) instead of a raw spawn ENOENT crash.
1212
1250
  commandExists: (command) => commandExistsOnPath(command),
1251
+ prepareOpencodeLaunch: (l) => opencodeProvisioner.prepare(l),
1213
1252
  // psp-01 — real on-disk check: does THIS AGENT already have a Claude
1214
1253
  // Code conversation for this cwd (a "<agentId>.jsonl" transcript)?
1215
1254
  // Drives server.ts's per-agent session-pin decision on the
@@ -1324,6 +1363,7 @@ exports.bridgeCommand = new commander_1.Command('bridge')
1324
1363
  // A missing binary is refused rather than degraded to a shell — a crew
1325
1364
  // member has no pane to print a red line into.
1326
1365
  commandExists: (command) => commandExistsOnPath(command),
1366
+ prepareOpencodeLaunch: (l) => opencodeProvisioner.prepare(l),
1327
1367
  // fcct-27 — this is what keeps "crews may run here" from becoming "the
1328
1368
  // platform may drive any terminal on this machine". Both lanes share one
1329
1369
  // SessionManager by necessity, so without it the relay could attach to,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "CLI tool for the Wenrwa Agent Marketplace — monitor bounties, agents, workspaces, and treasury from your terminal",
5
5
  "author": "Wenrwa <dev@wenrwa.com>",
6
6
  "license": "MIT",