@wenrwa/marketplace-cli 0.7.5 → 0.7.6
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/dist/__tests__/bridge-config-mcp.test.js +47 -0
- package/dist/__tests__/bridge-launch-gate.test.d.ts +1 -0
- package/dist/__tests__/bridge-launch-gate.test.js +218 -0
- package/dist/__tests__/bridge-mcp-install.test.d.ts +1 -0
- package/dist/__tests__/bridge-mcp-install.test.js +100 -0
- package/dist/bridge/launchGate.d.ts +67 -0
- package/dist/bridge/launchGate.js +187 -0
- package/dist/bridge/mcpInstall.d.ts +39 -0
- package/dist/bridge/mcpInstall.js +128 -0
- package/dist/bridge/mcpRegister.d.ts +13 -1
- package/dist/bridge/mcpRegister.js +24 -4
- package/dist/bridge/sessionManager.d.ts +12 -0
- package/dist/bridge/sessionManager.js +15 -3
- package/dist/commands/bridge.js +30 -3
- package/package.json +2 -2
|
@@ -122,3 +122,50 @@ describe('MCP auto-registration', () => {
|
|
|
122
122
|
expect((0, mcpRegister_1.redactToken)(cmd)).toContain('wm_ct_…<redacted>');
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
|
+
describe('MCP registration — installed server (fast start)', () => {
|
|
126
|
+
const token = 'wm_ct_' + 'c'.repeat(64);
|
|
127
|
+
const server = { command: '/usr/local/bin/node', args: ['/Users/u/.wen-market/mcp/node_modules/@wenrwa/marketplace-mcp/dist/index.js'] };
|
|
128
|
+
const runner = (listOutput) => {
|
|
129
|
+
const calls = [];
|
|
130
|
+
const run = async (cmd, args) => {
|
|
131
|
+
calls.push([cmd, ...args]);
|
|
132
|
+
return { stdout: args.includes('list') ? listOutput : '', code: 0 };
|
|
133
|
+
};
|
|
134
|
+
return { calls, run };
|
|
135
|
+
};
|
|
136
|
+
it('registers the absolute installed command, never npx', async () => {
|
|
137
|
+
const { calls, run } = runner('');
|
|
138
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
|
|
139
|
+
expect(result.action).toBe('registered');
|
|
140
|
+
const add = calls.find((c) => c.includes('add'));
|
|
141
|
+
expect(add.slice(add.indexOf('--') + 1)).toEqual([server.command, ...server.args]);
|
|
142
|
+
expect(add).not.toContain('npx');
|
|
143
|
+
});
|
|
144
|
+
it('migrates an OLD `npx @wenrwa/marketplace-mcp` registration: remove then add the installed one', async () => {
|
|
145
|
+
const { calls, run } = runner('wenrwa-marketplace: npx @wenrwa/marketplace-mcp - ✓ Connected\n');
|
|
146
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
|
|
147
|
+
expect(result.action).toBe('migrated');
|
|
148
|
+
const removeIdx = calls.findIndex((c) => c.includes('remove'));
|
|
149
|
+
const addIdx = calls.findIndex((c) => c.includes('add'));
|
|
150
|
+
expect(removeIdx).toBeGreaterThan(-1);
|
|
151
|
+
expect(addIdx).toBeGreaterThan(removeIdx);
|
|
152
|
+
expect(calls[addIdx]).toContain(server.args[0]);
|
|
153
|
+
});
|
|
154
|
+
it('skips when the installed command is already registered', async () => {
|
|
155
|
+
const { calls, run } = runner(`wenrwa-marketplace: ${server.command} ${server.args[0]} - ✓ Connected\n`);
|
|
156
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
|
|
157
|
+
expect(result.action).toBe('already-registered');
|
|
158
|
+
expect(calls.some((c) => c.includes('add') || c.includes('remove'))).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
it('re-points a registration whose node binary moved', async () => {
|
|
161
|
+
const { run } = runner(`wenrwa-marketplace: /old/node ${server.args[0]} - ✓ Connected\n`);
|
|
162
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
|
|
163
|
+
expect(result.action).toBe('migrated');
|
|
164
|
+
});
|
|
165
|
+
it('fallback (no installed server): an existing registration is left alone', async () => {
|
|
166
|
+
const { calls, run } = runner(`wenrwa-marketplace: ${server.command} ${server.args[0]} - ✓ Connected\n`);
|
|
167
|
+
const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run });
|
|
168
|
+
expect(result.action).toBe('already-registered');
|
|
169
|
+
expect(calls.some((c) => c.includes('add'))).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const launchGate_1 = require("../bridge/launchGate");
|
|
4
|
+
const sessionManager_1 = require("../bridge/sessionManager");
|
|
5
|
+
class FakePty {
|
|
6
|
+
spawnOpts;
|
|
7
|
+
written = [];
|
|
8
|
+
resizes = [];
|
|
9
|
+
killed = false;
|
|
10
|
+
dataCbs = [];
|
|
11
|
+
exitCbs = [];
|
|
12
|
+
constructor(spawnOpts) {
|
|
13
|
+
this.spawnOpts = spawnOpts;
|
|
14
|
+
}
|
|
15
|
+
write(d) { this.written.push(d); }
|
|
16
|
+
resize(c, r) { this.resizes.push([c, r]); }
|
|
17
|
+
kill() { this.killed = true; }
|
|
18
|
+
onData(cb) { this.dataCbs.push(cb); }
|
|
19
|
+
onExit(cb) { this.exitCbs.push(cb); }
|
|
20
|
+
emitData(d) { for (const cb of this.dataCbs)
|
|
21
|
+
cb(d); }
|
|
22
|
+
emitExit(c) { for (const cb of this.exitCbs)
|
|
23
|
+
cb(c); }
|
|
24
|
+
}
|
|
25
|
+
/** Fake clock: timers fire only when advanced. */
|
|
26
|
+
const fakeClock = () => {
|
|
27
|
+
let t = 0;
|
|
28
|
+
let nextId = 1;
|
|
29
|
+
const timers = new Map();
|
|
30
|
+
return {
|
|
31
|
+
scheduleTimeout: (cb, ms) => { const id = nextId++; timers.set(id, { at: t + ms, cb }); return id; },
|
|
32
|
+
cancelTimeout: (h) => { timers.delete(h); },
|
|
33
|
+
advance(ms) {
|
|
34
|
+
t += ms;
|
|
35
|
+
for (const [id, timer] of [...timers]) {
|
|
36
|
+
if (timer.at <= t && timers.has(id)) {
|
|
37
|
+
timers.delete(id);
|
|
38
|
+
timer.cb();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const spawner = () => {
|
|
45
|
+
const ptys = [];
|
|
46
|
+
const spawn = (size) => { const p = new FakePty(size); ptys.push(p); return p; };
|
|
47
|
+
return { ptys, spawn };
|
|
48
|
+
};
|
|
49
|
+
const collect = (pty) => {
|
|
50
|
+
const out = [];
|
|
51
|
+
pty.onData((d) => out.push(d));
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
54
|
+
describe('LaunchGate', () => {
|
|
55
|
+
it('starts two at once; the third queues with "Starting… (2 ahead)" visible immediately', () => {
|
|
56
|
+
const clock = fakeClock();
|
|
57
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
58
|
+
const { ptys, spawn } = spawner();
|
|
59
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
60
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
61
|
+
const third = gate.launch(spawn, { cols: 80, rows: 24 });
|
|
62
|
+
const out = collect(third); // subscribed AFTER launch, as SessionManager does
|
|
63
|
+
expect(ptys).toHaveLength(2);
|
|
64
|
+
expect(gate.queuedCount()).toBe(1);
|
|
65
|
+
expect(out.join('')).toContain('Starting… (2 ahead)');
|
|
66
|
+
});
|
|
67
|
+
it('a starting launch frees its slot on FIRST OUTPUT, and the queue drains FIFO', () => {
|
|
68
|
+
const clock = fakeClock();
|
|
69
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
70
|
+
const { ptys, spawn } = spawner();
|
|
71
|
+
const a = gate.launch(spawn, { cols: 80, rows: 24 });
|
|
72
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
73
|
+
const c = gate.launch(spawn, { cols: 100, rows: 30 });
|
|
74
|
+
const d = gate.launch(spawn, { cols: 80, rows: 24 });
|
|
75
|
+
const outA = collect(a);
|
|
76
|
+
const outC = collect(c);
|
|
77
|
+
const outD = collect(d);
|
|
78
|
+
expect(outD.join('')).toContain('Starting… (3 ahead)');
|
|
79
|
+
ptys[0].emitData('hello');
|
|
80
|
+
expect(outA).toEqual(['hello']);
|
|
81
|
+
expect(ptys).toHaveLength(3);
|
|
82
|
+
expect(ptys[2].spawnOpts).toMatchObject({ cols: 100, rows: 30 }); // c went first
|
|
83
|
+
expect(outD[outD.length - 1]).toBe((0, launchGate_1.queuedLine)(2)); // renumbered in place
|
|
84
|
+
ptys[2].emitData('claude ui');
|
|
85
|
+
expect(outC[outC.length - 1]).toBe('claude ui');
|
|
86
|
+
expect(ptys).toHaveLength(4);
|
|
87
|
+
});
|
|
88
|
+
it('a launch that prints nothing frees its slot after 8s', () => {
|
|
89
|
+
const clock = fakeClock();
|
|
90
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
91
|
+
const { ptys, spawn } = spawner();
|
|
92
|
+
for (let i = 0; i < 3; i++)
|
|
93
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
94
|
+
clock.advance(7_999);
|
|
95
|
+
expect(ptys).toHaveLength(2);
|
|
96
|
+
clock.advance(1);
|
|
97
|
+
expect(ptys).toHaveLength(3);
|
|
98
|
+
});
|
|
99
|
+
it('an exit frees the slot too', () => {
|
|
100
|
+
const clock = fakeClock();
|
|
101
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
102
|
+
const { ptys, spawn } = spawner();
|
|
103
|
+
for (let i = 0; i < 3; i++)
|
|
104
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
105
|
+
ptys[1].emitExit(1);
|
|
106
|
+
expect(ptys).toHaveLength(3);
|
|
107
|
+
});
|
|
108
|
+
it('killing a queued launch removes it without spawning; the ones behind move up', () => {
|
|
109
|
+
const clock = fakeClock();
|
|
110
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
111
|
+
const { ptys, spawn } = spawner();
|
|
112
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
113
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
114
|
+
const q1 = gate.launch(spawn, { cols: 80, rows: 24 });
|
|
115
|
+
const q2 = gate.launch(spawn, { cols: 80, rows: 24 });
|
|
116
|
+
const out2 = collect(q2);
|
|
117
|
+
q1.kill();
|
|
118
|
+
expect(gate.queuedCount()).toBe(1);
|
|
119
|
+
expect(out2[out2.length - 1]).toBe((0, launchGate_1.queuedLine)(2));
|
|
120
|
+
ptys[0].emitData('x');
|
|
121
|
+
expect(ptys).toHaveLength(3);
|
|
122
|
+
clock.advance(10_000);
|
|
123
|
+
expect(ptys).toHaveLength(3); // the killed one never spawns
|
|
124
|
+
});
|
|
125
|
+
it('input typed while queued is delivered once the process starts; resize is applied', () => {
|
|
126
|
+
const clock = fakeClock();
|
|
127
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
128
|
+
const { ptys, spawn } = spawner();
|
|
129
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
130
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
131
|
+
const q = gate.launch(spawn, { cols: 80, rows: 24 });
|
|
132
|
+
q.write('hi');
|
|
133
|
+
q.resize(120, 40);
|
|
134
|
+
ptys[0].emitData('x');
|
|
135
|
+
expect(ptys[2].spawnOpts).toMatchObject({ cols: 120, rows: 40 });
|
|
136
|
+
expect(ptys[2].written).toEqual(['hi']);
|
|
137
|
+
});
|
|
138
|
+
it('a queued spawn that throws prints a line and exits instead of hanging the pane', () => {
|
|
139
|
+
const clock = fakeClock();
|
|
140
|
+
const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
|
|
141
|
+
const { ptys, spawn } = spawner();
|
|
142
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
143
|
+
gate.launch(spawn, { cols: 80, rows: 24 });
|
|
144
|
+
const q = gate.launch(() => { throw new Error('EAGAIN'); }, { cols: 80, rows: 24 });
|
|
145
|
+
const out = collect(q);
|
|
146
|
+
const exits = [];
|
|
147
|
+
q.onExit((c) => exits.push(c));
|
|
148
|
+
ptys[0].emitData('x');
|
|
149
|
+
expect(out.join('')).toContain("Claude couldn't start: EAGAIN");
|
|
150
|
+
expect(exits).toEqual([1]);
|
|
151
|
+
expect(gate.startingCount()).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
it('an immediate spawn that throws propagates and frees the slot', () => {
|
|
154
|
+
const gate = new launchGate_1.LaunchGate({ ...fakeClock(), log: () => { } });
|
|
155
|
+
expect(() => gate.launch(() => { throw new Error('ENOMEM'); }, { cols: 80, rows: 24 })).toThrow('ENOMEM');
|
|
156
|
+
expect(gate.startingCount()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('SessionManager staggers agent launches, never shells', () => {
|
|
160
|
+
const snapshot = () => ({
|
|
161
|
+
write: () => Promise.resolve(),
|
|
162
|
+
resize: () => { },
|
|
163
|
+
serialize: () => Promise.resolve(''),
|
|
164
|
+
dispose: () => { },
|
|
165
|
+
});
|
|
166
|
+
const setup = (everyOpenIsAgentLaunch = false) => {
|
|
167
|
+
const clock = fakeClock();
|
|
168
|
+
const ptys = [];
|
|
169
|
+
const factory = { spawn: (o) => { const p = new FakePty(o); ptys.push(p); return p; } };
|
|
170
|
+
const sm = new sessionManager_1.SessionManager(factory, {
|
|
171
|
+
snapshotFactory: snapshot,
|
|
172
|
+
launchGate: { ...clock, log: () => { } },
|
|
173
|
+
launchSupervisor: { settle: (fn) => fn(), log: () => { } },
|
|
174
|
+
everyOpenIsAgentLaunch,
|
|
175
|
+
});
|
|
176
|
+
return { sm, ptys, clock };
|
|
177
|
+
};
|
|
178
|
+
it('seven claude opens → two processes now, all seven sessions registered, the rest follow as output arrives', () => {
|
|
179
|
+
const { sm, ptys } = setup();
|
|
180
|
+
const ids = Array.from({ length: 7 }, () => sm.open({ cols: 80, rows: 24, command: 'claude', args: [] }));
|
|
181
|
+
expect(sm.list()).toEqual(ids);
|
|
182
|
+
expect(ptys).toHaveLength(2);
|
|
183
|
+
ptys[0].emitData('ready');
|
|
184
|
+
ptys[1].emitData('ready');
|
|
185
|
+
expect(ptys).toHaveLength(4);
|
|
186
|
+
});
|
|
187
|
+
it('a queued pane is never blank: its snapshot (what an attach replays) holds the Starting line', () => {
|
|
188
|
+
const writes = [];
|
|
189
|
+
const recording = () => {
|
|
190
|
+
const mine = [];
|
|
191
|
+
writes.push(mine);
|
|
192
|
+
return { write: (d) => { mine.push(d); return Promise.resolve(); }, resize: () => { }, serialize: () => Promise.resolve(mine.join('')), dispose: () => { } };
|
|
193
|
+
};
|
|
194
|
+
const factory = { spawn: (o) => new FakePty(o) };
|
|
195
|
+
const sm = new sessionManager_1.SessionManager(factory, { snapshotFactory: recording, launchGate: { ...fakeClock(), log: () => { } } });
|
|
196
|
+
for (let i = 0; i < 3; i++)
|
|
197
|
+
sm.open({ cols: 80, rows: 24, command: 'claude', args: [] });
|
|
198
|
+
expect(writes[2].join('')).toContain('Starting… (2 ahead)');
|
|
199
|
+
expect(writes[0].join('')).toBe('');
|
|
200
|
+
});
|
|
201
|
+
it('plain shells are never delayed, even with claude launches queued', () => {
|
|
202
|
+
const { sm, ptys } = setup();
|
|
203
|
+
for (let i = 0; i < 4; i++)
|
|
204
|
+
sm.open({ cols: 80, rows: 24, command: 'claude', args: [] });
|
|
205
|
+
expect(ptys).toHaveLength(2);
|
|
206
|
+
sm.open({ cols: 80, rows: 24 });
|
|
207
|
+
expect(ptys).toHaveLength(3);
|
|
208
|
+
expect(ptys[2].spawnOpts.command).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
it('hosted lane (everyOpenIsAgentLaunch): shell opens are gated, since autolaunch turns each into claude', () => {
|
|
211
|
+
const { sm, ptys, clock } = setup(true);
|
|
212
|
+
for (let i = 0; i < 3; i++)
|
|
213
|
+
sm.open({ cols: 80, rows: 24 });
|
|
214
|
+
expect(ptys).toHaveLength(2);
|
|
215
|
+
clock.advance(8_000);
|
|
216
|
+
expect(ptys).toHaveLength(3);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
const path = __importStar(require("path"));
|
|
37
|
+
const mcpInstall_1 = require("../bridge/mcpInstall");
|
|
38
|
+
const BASE = '/home/u/.wen-market/mcp';
|
|
39
|
+
const PKG = path.join(BASE, 'node_modules', '@wenrwa', 'marketplace-mcp', 'package.json');
|
|
40
|
+
const ENTRY = (0, mcpInstall_1.mcpEntryPath)(BASE);
|
|
41
|
+
/** In-memory fs; `npmInstalls` (if given) is what a successful npm run leaves behind. */
|
|
42
|
+
const fakeDeps = (opts = {}) => {
|
|
43
|
+
const files = { ...(opts.files ?? {}) };
|
|
44
|
+
const npmCalls = [];
|
|
45
|
+
const deps = {
|
|
46
|
+
runNpm: async (args) => {
|
|
47
|
+
npmCalls.push(args);
|
|
48
|
+
if (opts.npm === 'throw')
|
|
49
|
+
throw new Error('ENOTFOUND registry.npmjs.org');
|
|
50
|
+
if (opts.npm === 'fail')
|
|
51
|
+
return { code: 1 };
|
|
52
|
+
files[PKG] = JSON.stringify({ version: opts.installs ?? mcpInstall_1.MCP_PINNED_VERSION });
|
|
53
|
+
files[ENTRY] = '// server';
|
|
54
|
+
return { code: 0 };
|
|
55
|
+
},
|
|
56
|
+
exists: (p) => p in files,
|
|
57
|
+
readFile: (p) => { if (!(p in files))
|
|
58
|
+
throw new Error('ENOENT'); return files[p]; },
|
|
59
|
+
writeFile: (p, c) => { files[p] = c; },
|
|
60
|
+
mkdirp: () => { },
|
|
61
|
+
now: () => opts.now ?? 1_000_000_000,
|
|
62
|
+
nodePath: '/usr/local/bin/node',
|
|
63
|
+
};
|
|
64
|
+
return { deps, files, npmCalls };
|
|
65
|
+
};
|
|
66
|
+
const installed = (version) => ({ [PKG]: JSON.stringify({ version }), [ENTRY]: '// server' });
|
|
67
|
+
describe('ensureMcpInstalled', () => {
|
|
68
|
+
it('already at the pinned version → no npm run, absolute node + entry', async () => {
|
|
69
|
+
const { deps, npmCalls } = fakeDeps({ files: installed(mcpInstall_1.MCP_PINNED_VERSION) });
|
|
70
|
+
const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
|
|
71
|
+
expect(npmCalls).toHaveLength(0);
|
|
72
|
+
expect(r).toMatchObject({ ok: true, action: 'present', command: '/usr/local/bin/node', args: [ENTRY] });
|
|
73
|
+
});
|
|
74
|
+
it('missing → npm install --prefix <baseDir> of the pinned version', async () => {
|
|
75
|
+
const { deps, npmCalls, files } = fakeDeps();
|
|
76
|
+
const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
|
|
77
|
+
expect(r).toMatchObject({ ok: true, action: 'installed', version: mcpInstall_1.MCP_PINNED_VERSION });
|
|
78
|
+
expect(npmCalls[0]).toEqual(expect.arrayContaining(['install', '--prefix', BASE, `@wenrwa/marketplace-mcp@${mcpInstall_1.MCP_PINNED_VERSION}`]));
|
|
79
|
+
expect(files[path.join(BASE, 'package.json')]).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
it('offline with nothing installed → not ok (caller falls back to npx), and records the failure', async () => {
|
|
82
|
+
const { deps, files } = fakeDeps({ npm: 'throw' });
|
|
83
|
+
const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
|
|
84
|
+
expect(r).toMatchObject({ ok: false, action: 'failed' });
|
|
85
|
+
expect(files[path.join(BASE, '.last-failed-install')]).toBe('1000000000');
|
|
86
|
+
});
|
|
87
|
+
it('a recent failure is not retried for INSTALL_RETRY_MS; after that it is', async () => {
|
|
88
|
+
const stamp = { [path.join(BASE, '.last-failed-install')]: '1000000000' };
|
|
89
|
+
const soon = fakeDeps({ files: stamp, now: 1_000_000_000 + mcpInstall_1.INSTALL_RETRY_MS - 1 });
|
|
90
|
+
expect(await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, soon.deps)).toMatchObject({ ok: false, action: 'skipped-recent-failure' });
|
|
91
|
+
expect(soon.npmCalls).toHaveLength(0);
|
|
92
|
+
const later = fakeDeps({ files: stamp, now: 1_000_000_000 + mcpInstall_1.INSTALL_RETRY_MS });
|
|
93
|
+
expect(await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, later.deps)).toMatchObject({ ok: true, action: 'installed' });
|
|
94
|
+
});
|
|
95
|
+
it('upgrade fails but an older copy is installed → keep using it', async () => {
|
|
96
|
+
const { deps } = fakeDeps({ files: installed('0.8.0'), npm: 'fail' });
|
|
97
|
+
const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
|
|
98
|
+
expect(r).toMatchObject({ ok: true, action: 'stale-copy', version: '0.8.0', args: [ENTRY] });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch gate — staggers `claude` start-ups so N agents opened at once don't
|
|
3
|
+
* all boot in the same second.
|
|
4
|
+
*
|
|
5
|
+
* Observed 2026-10-04: seven agents added together spawned seven `claude`
|
|
6
|
+
* processes, each spawning its MCP server, and the machine's load average hit
|
|
7
|
+
* 82 — every pane sat blank for one to two minutes. Booting them a couple at
|
|
8
|
+
* a time finishes sooner for all of them.
|
|
9
|
+
*
|
|
10
|
+
* Rules:
|
|
11
|
+
* - at most `maxStarting` launches are "starting" at once (default 2);
|
|
12
|
+
* - a launch stops "starting" at its FIRST OUTPUT, or after `releaseAfterMs`
|
|
13
|
+
* (default 8s), or when it exits/is killed — whichever comes first;
|
|
14
|
+
* - further launches queue FIFO; a queued pane immediately shows
|
|
15
|
+
* "Starting… (N ahead)" so it is never blank, and the count updates as the
|
|
16
|
+
* queue moves.
|
|
17
|
+
*
|
|
18
|
+
* Only agent launches go through here (SessionManager decides which); plain
|
|
19
|
+
* shells are never delayed.
|
|
20
|
+
*
|
|
21
|
+
* `gate.launch(spawn)` returns a PtyLike synchronously, so SessionManager can
|
|
22
|
+
* register the session and a pane can attach before the process exists.
|
|
23
|
+
* Writes typed while queued are held (capped) and delivered after spawn;
|
|
24
|
+
* resizes are remembered and applied.
|
|
25
|
+
*/
|
|
26
|
+
import type { PtyLike } from './sessionManager';
|
|
27
|
+
export declare const DEFAULT_MAX_STARTING = 2;
|
|
28
|
+
export declare const DEFAULT_RELEASE_AFTER_MS = 8000;
|
|
29
|
+
export interface LaunchGateOptions {
|
|
30
|
+
maxStarting?: number;
|
|
31
|
+
releaseAfterMs?: number;
|
|
32
|
+
scheduleTimeout?: (cb: () => void, ms: number) => unknown;
|
|
33
|
+
cancelTimeout?: (handle: unknown) => void;
|
|
34
|
+
log?: (msg: string) => void;
|
|
35
|
+
}
|
|
36
|
+
/** The line a queued pane shows. `\r\x1b[2K` rewrites it in place as the count moves. */
|
|
37
|
+
export declare function queuedLine(ahead: number): string;
|
|
38
|
+
export declare class LaunchGate {
|
|
39
|
+
private maxStarting;
|
|
40
|
+
private releaseAfterMs;
|
|
41
|
+
private scheduleTimeout;
|
|
42
|
+
private cancelTimeout;
|
|
43
|
+
private log;
|
|
44
|
+
private starting;
|
|
45
|
+
private queue;
|
|
46
|
+
constructor(opts?: LaunchGateOptions);
|
|
47
|
+
/** Launches currently in their start-up window. */
|
|
48
|
+
startingCount(): number;
|
|
49
|
+
/** Launches waiting for a slot. */
|
|
50
|
+
queuedCount(): number;
|
|
51
|
+
/**
|
|
52
|
+
* Gate one launch. `spawn` receives the latest geometry. If a slot is free
|
|
53
|
+
* it runs synchronously (a throw propagates, exactly like an ungated
|
|
54
|
+
* spawn); otherwise the launch queues and the returned PtyLike stands in.
|
|
55
|
+
*/
|
|
56
|
+
launch(spawn: (size: {
|
|
57
|
+
cols: number;
|
|
58
|
+
rows: number;
|
|
59
|
+
}) => PtyLike, size: {
|
|
60
|
+
cols: number;
|
|
61
|
+
rows: number;
|
|
62
|
+
}): PtyLike;
|
|
63
|
+
/** Start queued launches while slots are free. */
|
|
64
|
+
private drain;
|
|
65
|
+
/** Tell every queued launch how many are ahead of it now. */
|
|
66
|
+
private renumber;
|
|
67
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LaunchGate = exports.DEFAULT_RELEASE_AFTER_MS = exports.DEFAULT_MAX_STARTING = void 0;
|
|
4
|
+
exports.queuedLine = queuedLine;
|
|
5
|
+
exports.DEFAULT_MAX_STARTING = 2;
|
|
6
|
+
exports.DEFAULT_RELEASE_AFTER_MS = 8_000;
|
|
7
|
+
/** Cap on keystrokes held for a queued launch. */
|
|
8
|
+
const MAX_HELD_INPUT = 4 * 1024;
|
|
9
|
+
/** The line a queued pane shows. `\r\x1b[2K` rewrites it in place as the count moves. */
|
|
10
|
+
function queuedLine(ahead) {
|
|
11
|
+
return `\r\x1b[2K\x1b[2mStarting… (${ahead} ahead)\x1b[0m`;
|
|
12
|
+
}
|
|
13
|
+
/** Wipes the queued line just before the real process starts drawing. */
|
|
14
|
+
const CLEAR_QUEUED_LINE = '\r\x1b[2K';
|
|
15
|
+
class LaunchGate {
|
|
16
|
+
maxStarting;
|
|
17
|
+
releaseAfterMs;
|
|
18
|
+
scheduleTimeout;
|
|
19
|
+
cancelTimeout;
|
|
20
|
+
log;
|
|
21
|
+
starting = 0;
|
|
22
|
+
queue = [];
|
|
23
|
+
constructor(opts = {}) {
|
|
24
|
+
this.maxStarting = Math.max(1, opts.maxStarting ?? exports.DEFAULT_MAX_STARTING);
|
|
25
|
+
this.releaseAfterMs = opts.releaseAfterMs ?? exports.DEFAULT_RELEASE_AFTER_MS;
|
|
26
|
+
this.scheduleTimeout = opts.scheduleTimeout ?? ((cb, ms) => {
|
|
27
|
+
const h = setTimeout(cb, ms);
|
|
28
|
+
h.unref?.(); // a pending release must never keep the process alive
|
|
29
|
+
return h;
|
|
30
|
+
});
|
|
31
|
+
this.cancelTimeout = opts.cancelTimeout ?? ((h) => clearTimeout(h));
|
|
32
|
+
this.log = opts.log ?? ((m) => console.error(m));
|
|
33
|
+
}
|
|
34
|
+
/** Launches currently in their start-up window. */
|
|
35
|
+
startingCount() {
|
|
36
|
+
return this.starting;
|
|
37
|
+
}
|
|
38
|
+
/** Launches waiting for a slot. */
|
|
39
|
+
queuedCount() {
|
|
40
|
+
return this.queue.length;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Gate one launch. `spawn` receives the latest geometry. If a slot is free
|
|
44
|
+
* it runs synchronously (a throw propagates, exactly like an ungated
|
|
45
|
+
* spawn); otherwise the launch queues and the returned PtyLike stands in.
|
|
46
|
+
*/
|
|
47
|
+
launch(spawn, size) {
|
|
48
|
+
const dataListeners = [];
|
|
49
|
+
const exitListeners = [];
|
|
50
|
+
/** Output emitted before SessionManager subscribed (the queued line). */
|
|
51
|
+
let unheard = [];
|
|
52
|
+
const emitData = (d) => {
|
|
53
|
+
if (dataListeners.length === 0) {
|
|
54
|
+
unheard.push(d);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const cb of dataListeners)
|
|
58
|
+
cb(d);
|
|
59
|
+
};
|
|
60
|
+
const emitExit = (code) => { for (const cb of exitListeners)
|
|
61
|
+
cb(code); };
|
|
62
|
+
let cols = size.cols;
|
|
63
|
+
let rows = size.rows;
|
|
64
|
+
let real = null;
|
|
65
|
+
let killed = false;
|
|
66
|
+
let heldInput = '';
|
|
67
|
+
let queuedEntry = null;
|
|
68
|
+
// Slot bookkeeping for THIS launch: released exactly once.
|
|
69
|
+
let holdsSlot = false;
|
|
70
|
+
let releaseTimer = null;
|
|
71
|
+
const release = () => {
|
|
72
|
+
if (!holdsSlot)
|
|
73
|
+
return;
|
|
74
|
+
holdsSlot = false;
|
|
75
|
+
if (releaseTimer !== null)
|
|
76
|
+
this.cancelTimeout(releaseTimer);
|
|
77
|
+
releaseTimer = null;
|
|
78
|
+
this.starting -= 1;
|
|
79
|
+
this.drain();
|
|
80
|
+
};
|
|
81
|
+
const startReal = (fromQueue) => {
|
|
82
|
+
holdsSlot = true;
|
|
83
|
+
this.starting += 1;
|
|
84
|
+
let pty;
|
|
85
|
+
try {
|
|
86
|
+
pty = spawn({ cols, rows });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
release();
|
|
90
|
+
if (!fromQueue)
|
|
91
|
+
throw err;
|
|
92
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
+
this.log(`[bridge] queued claude launch failed to spawn: ${msg}`);
|
|
94
|
+
emitData(`${CLEAR_QUEUED_LINE}\x1b[33mClaude couldn't start: ${msg}. Reopen the pane to try again.\x1b[0m\r\n`);
|
|
95
|
+
emitExit(1);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
real = pty;
|
|
99
|
+
releaseTimer = this.scheduleTimeout(release, this.releaseAfterMs);
|
|
100
|
+
if (fromQueue)
|
|
101
|
+
emitData(CLEAR_QUEUED_LINE);
|
|
102
|
+
pty.onData((d) => {
|
|
103
|
+
release();
|
|
104
|
+
emitData(d);
|
|
105
|
+
});
|
|
106
|
+
pty.onExit((code) => {
|
|
107
|
+
release();
|
|
108
|
+
emitExit(code);
|
|
109
|
+
});
|
|
110
|
+
if (fromQueue && heldInput) {
|
|
111
|
+
const input = heldInput;
|
|
112
|
+
heldInput = '';
|
|
113
|
+
pty.write(input);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
if (this.starting < this.maxStarting && this.queue.length === 0) {
|
|
117
|
+
startReal(false);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
queuedEntry = {
|
|
121
|
+
start: () => { queuedEntry = null; if (!killed)
|
|
122
|
+
startReal(true); },
|
|
123
|
+
notify: (ahead) => emitData(queuedLine(ahead)),
|
|
124
|
+
};
|
|
125
|
+
this.queue.push(queuedEntry);
|
|
126
|
+
emitData(queuedLine(this.queue.length - 1 + this.starting));
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
write: (d) => {
|
|
130
|
+
if (real) {
|
|
131
|
+
real.write(d);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (heldInput.length + d.length <= MAX_HELD_INPUT)
|
|
135
|
+
heldInput += d;
|
|
136
|
+
},
|
|
137
|
+
resize: (c, r) => {
|
|
138
|
+
cols = c;
|
|
139
|
+
rows = r;
|
|
140
|
+
real?.resize(c, r);
|
|
141
|
+
},
|
|
142
|
+
kill: () => {
|
|
143
|
+
killed = true;
|
|
144
|
+
if (real) {
|
|
145
|
+
real.kill();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Never started: just leave the queue. No exit is emitted — the caller
|
|
149
|
+
// killed it and is already tearing the session down, exactly as for a
|
|
150
|
+
// session closed before its process printed anything.
|
|
151
|
+
if (queuedEntry) {
|
|
152
|
+
const i = this.queue.indexOf(queuedEntry);
|
|
153
|
+
if (i >= 0)
|
|
154
|
+
this.queue.splice(i, 1);
|
|
155
|
+
queuedEntry = null;
|
|
156
|
+
this.renumber();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
onData: (cb) => {
|
|
160
|
+
dataListeners.push(cb);
|
|
161
|
+
if (unheard.length) {
|
|
162
|
+
const pending = unheard;
|
|
163
|
+
unheard = [];
|
|
164
|
+
for (const d of pending)
|
|
165
|
+
cb(d);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
onExit: (cb) => { exitListeners.push(cb); },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Start queued launches while slots are free. */
|
|
172
|
+
drain() {
|
|
173
|
+
let moved = false;
|
|
174
|
+
while (this.starting < this.maxStarting && this.queue.length > 0) {
|
|
175
|
+
const next = this.queue.shift();
|
|
176
|
+
moved = true;
|
|
177
|
+
next.start();
|
|
178
|
+
}
|
|
179
|
+
if (moved)
|
|
180
|
+
this.renumber();
|
|
181
|
+
}
|
|
182
|
+
/** Tell every queued launch how many are ahead of it now. */
|
|
183
|
+
renumber() {
|
|
184
|
+
this.queue.forEach((entry, i) => entry.notify(i + this.starting));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
exports.LaunchGate = LaunchGate;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare const MCP_PACKAGE = "@wenrwa/marketplace-mcp";
|
|
2
|
+
/** Bump on each CLI release that should move to a newer MCP server. */
|
|
3
|
+
export declare const MCP_PINNED_VERSION = "0.9.0";
|
|
4
|
+
/** After a failed install, wait this long before trying again. */
|
|
5
|
+
export declare const INSTALL_RETRY_MS: number;
|
|
6
|
+
export interface McpInstallDeps {
|
|
7
|
+
/** Run npm. Rejects (or returns non-zero) on failure. */
|
|
8
|
+
runNpm: (args: string[]) => Promise<{
|
|
9
|
+
code: number;
|
|
10
|
+
}>;
|
|
11
|
+
exists: (p: string) => boolean;
|
|
12
|
+
readFile: (p: string) => string;
|
|
13
|
+
writeFile: (p: string, content: string) => void;
|
|
14
|
+
mkdirp: (dir: string) => void;
|
|
15
|
+
now: () => number;
|
|
16
|
+
/** Absolute node binary that runs the server (the bridge's own). */
|
|
17
|
+
nodePath: string;
|
|
18
|
+
}
|
|
19
|
+
export interface McpInstallOpts {
|
|
20
|
+
/** e.g. ~/.wen-market/mcp */
|
|
21
|
+
baseDir: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
}
|
|
24
|
+
export type McpInstallResult = {
|
|
25
|
+
ok: true;
|
|
26
|
+
action: 'present' | 'installed' | 'stale-copy';
|
|
27
|
+
command: string;
|
|
28
|
+
args: string[];
|
|
29
|
+
entry: string;
|
|
30
|
+
version: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
action: 'failed' | 'skipped-recent-failure';
|
|
35
|
+
error: string;
|
|
36
|
+
};
|
|
37
|
+
/** Where the installed server's entry point lives under `baseDir`. */
|
|
38
|
+
export declare function mcpEntryPath(baseDir: string): string;
|
|
39
|
+
export declare function ensureMcpInstalled(opts: McpInstallOpts, deps: McpInstallDeps): Promise<McpInstallResult>;
|
|
@@ -0,0 +1,128 @@
|
|
|
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.INSTALL_RETRY_MS = exports.MCP_PINNED_VERSION = exports.MCP_PACKAGE = void 0;
|
|
37
|
+
exports.mcpEntryPath = mcpEntryPath;
|
|
38
|
+
exports.ensureMcpInstalled = ensureMcpInstalled;
|
|
39
|
+
/**
|
|
40
|
+
* A pinned, installed copy of @wenrwa/marketplace-mcp for the LOCAL lane.
|
|
41
|
+
*
|
|
42
|
+
* Registering the MCP server as `npx @wenrwa/marketplace-mcp` made every
|
|
43
|
+
* `claude` launch run `npm exec` — a registry round-trip plus package
|
|
44
|
+
* resolution per agent. Seven agents opened together meant seven of those at
|
|
45
|
+
* once (load average 82 on 2026-10-04, panes blank for 1–2 min).
|
|
46
|
+
*
|
|
47
|
+
* Instead the bridge installs the server ONCE under ~/.wen-market/mcp and the
|
|
48
|
+
* registration points at it with an absolute command (`<node> <entry>`), so a
|
|
49
|
+
* launch just starts node on a file already on disk.
|
|
50
|
+
*
|
|
51
|
+
* - Version: MCP_PINNED_VERSION — what this CLI release was built against,
|
|
52
|
+
* bumped with each release (the hosted image pins the same in its
|
|
53
|
+
* Dockerfile).
|
|
54
|
+
* - Already installed at that version → nothing runs.
|
|
55
|
+
* - Install fails (offline, registry down) → an older installed copy is still
|
|
56
|
+
* used if one exists; otherwise the caller falls back to npx. A failure is
|
|
57
|
+
* not retried for INSTALL_RETRY_MS, so an offline laptop does not pay an
|
|
58
|
+
* npm timeout on every bridge start.
|
|
59
|
+
*
|
|
60
|
+
* The hosted container never comes through here: its image installs the
|
|
61
|
+
* server globally and entrypoint.sh registers it.
|
|
62
|
+
*/
|
|
63
|
+
const path = __importStar(require("path"));
|
|
64
|
+
exports.MCP_PACKAGE = '@wenrwa/marketplace-mcp';
|
|
65
|
+
/** Bump on each CLI release that should move to a newer MCP server. */
|
|
66
|
+
exports.MCP_PINNED_VERSION = '0.9.0';
|
|
67
|
+
/** After a failed install, wait this long before trying again. */
|
|
68
|
+
exports.INSTALL_RETRY_MS = 6 * 60 * 60 * 1000;
|
|
69
|
+
/** Where the installed server's entry point lives under `baseDir`. */
|
|
70
|
+
function mcpEntryPath(baseDir) {
|
|
71
|
+
return path.join(baseDir, 'node_modules', '@wenrwa', 'marketplace-mcp', 'dist', 'index.js');
|
|
72
|
+
}
|
|
73
|
+
function installedVersion(baseDir, deps) {
|
|
74
|
+
const pkg = path.join(baseDir, 'node_modules', '@wenrwa', 'marketplace-mcp', 'package.json');
|
|
75
|
+
if (!deps.exists(pkg) || !deps.exists(mcpEntryPath(baseDir)))
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
const v = JSON.parse(deps.readFile(pkg)).version;
|
|
79
|
+
return typeof v === 'string' ? v : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function ensureMcpInstalled(opts, deps) {
|
|
86
|
+
const version = opts.version ?? exports.MCP_PINNED_VERSION;
|
|
87
|
+
const entry = mcpEntryPath(opts.baseDir);
|
|
88
|
+
const ok = (action, v, error) => ({
|
|
89
|
+
ok: true, action, command: deps.nodePath, args: [entry], entry, version: v, ...(error ? { error } : {}),
|
|
90
|
+
});
|
|
91
|
+
const have = installedVersion(opts.baseDir, deps);
|
|
92
|
+
if (have === version)
|
|
93
|
+
return ok('present', have);
|
|
94
|
+
const stampPath = path.join(opts.baseDir, '.last-failed-install');
|
|
95
|
+
const fallback = (action, error) => have ? ok('stale-copy', have, error) : { ok: false, action, error };
|
|
96
|
+
try {
|
|
97
|
+
const last = deps.exists(stampPath) ? Number(deps.readFile(stampPath).trim()) : NaN;
|
|
98
|
+
if (Number.isFinite(last) && deps.now() - last < exports.INSTALL_RETRY_MS) {
|
|
99
|
+
return fallback('skipped-recent-failure', 'a recent install attempt failed; retrying later');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch { /* unreadable stamp → just try */ }
|
|
103
|
+
let error;
|
|
104
|
+
try {
|
|
105
|
+
deps.mkdirp(opts.baseDir);
|
|
106
|
+
const pkgJson = path.join(opts.baseDir, 'package.json');
|
|
107
|
+
// Own package.json so npm never walks up into some other project.
|
|
108
|
+
if (!deps.exists(pkgJson))
|
|
109
|
+
deps.writeFile(pkgJson, JSON.stringify({ name: 'wen-market-mcp', private: true }, null, 2) + '\n');
|
|
110
|
+
const res = await deps.runNpm([
|
|
111
|
+
'install', '--prefix', opts.baseDir,
|
|
112
|
+
'--no-audit', '--no-fund', '--omit=dev', '--no-package-lock',
|
|
113
|
+
`${exports.MCP_PACKAGE}@${version}`,
|
|
114
|
+
]);
|
|
115
|
+
const now = installedVersion(opts.baseDir, deps);
|
|
116
|
+
if (res.code === 0 && now === version)
|
|
117
|
+
return ok('installed', now);
|
|
118
|
+
error = `npm install exited ${res.code}${now ? ` (found ${now})` : ''}`;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
error = err instanceof Error ? err.message : String(err);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
deps.writeFile(stampPath, String(deps.now()));
|
|
125
|
+
}
|
|
126
|
+
catch { /* best effort */ }
|
|
127
|
+
return fallback('failed', error);
|
|
128
|
+
}
|
|
@@ -17,12 +17,24 @@ export interface McpRegisterOpts {
|
|
|
17
17
|
/** Set when a fresh token was just provided: an existing registration
|
|
18
18
|
* carries the OLD token env and must be replaced, not skipped. */
|
|
19
19
|
forceUpdate?: boolean;
|
|
20
|
+
/** The installed server to register (mcpInstall.ts): an absolute command,
|
|
21
|
+
* e.g. `<node> ~/.wen-market/mcp/.../dist/index.js`. Absent → the legacy
|
|
22
|
+
* `npx @wenrwa/marketplace-mcp` (install failed / offline). */
|
|
23
|
+
server?: {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
26
|
+
};
|
|
20
27
|
}
|
|
21
28
|
export interface McpRegisterResult {
|
|
22
|
-
|
|
29
|
+
/** `migrated`: an existing registration used another command (the old
|
|
30
|
+
* per-launch npx form) and was replaced with `server`. */
|
|
31
|
+
action: 'registered' | 're-registered' | 'migrated' | 'already-registered' | 'claude-not-found';
|
|
23
32
|
command?: string;
|
|
24
33
|
}
|
|
25
34
|
export declare const MCP_SERVER_NAME = "wenrwa-marketplace";
|
|
26
35
|
/** Connect Tokens must never appear in output — redact before printing. */
|
|
27
36
|
export declare function redactToken(text: string): string;
|
|
28
37
|
export declare function registerMcpServer(opts: McpRegisterOpts): Promise<McpRegisterResult>;
|
|
38
|
+
/** The `claude mcp list` line for our server, e.g.
|
|
39
|
+
* `wenrwa-marketplace: npx @wenrwa/marketplace-mcp - ✓ Connected`. */
|
|
40
|
+
export declare function registrationLine(listOutput: string): string | null;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.MCP_SERVER_NAME = void 0;
|
|
4
4
|
exports.redactToken = redactToken;
|
|
5
5
|
exports.registerMcpServer = registerMcpServer;
|
|
6
|
+
exports.registrationLine = registrationLine;
|
|
6
7
|
exports.MCP_SERVER_NAME = 'wenrwa-marketplace';
|
|
7
8
|
/** Connect Tokens must never appear in output — redact before printing. */
|
|
8
9
|
function redactToken(text) {
|
|
@@ -18,20 +19,39 @@ async function registerMcpServer(opts) {
|
|
|
18
19
|
return { action: 'claude-not-found' };
|
|
19
20
|
}
|
|
20
21
|
const exists = listOutput.includes(exports.MCP_SERVER_NAME);
|
|
21
|
-
|
|
22
|
+
// With an installed server to point at, a registration whose line does not
|
|
23
|
+
// run exactly that command (the old `npx @wenrwa/marketplace-mcp`, or a
|
|
24
|
+
// node binary that has since moved) is replaced. Without one (fallback), any existing
|
|
25
|
+
// registration is kept — never downgrade a working absolute one to npx.
|
|
26
|
+
const line = registrationLine(listOutput);
|
|
27
|
+
const wanted = opts.server ? [opts.server.command, ...opts.server.args].join(' ') : null;
|
|
28
|
+
const stale = wanted !== null && exists && !(line ?? '').includes(wanted);
|
|
29
|
+
if (exists && !opts.forceUpdate && !stale) {
|
|
22
30
|
return { action: 'already-registered' };
|
|
23
31
|
}
|
|
24
|
-
if (exists
|
|
32
|
+
if (exists) {
|
|
25
33
|
await opts.run('claude', ['mcp', 'remove', '--scope', 'user', exports.MCP_SERVER_NAME]).catch(() => { });
|
|
26
34
|
// Also clear a stale default(local)-scope entry from older bridge versions.
|
|
27
35
|
await opts.run('claude', ['mcp', 'remove', exports.MCP_SERVER_NAME]).catch(() => { });
|
|
28
36
|
}
|
|
37
|
+
const server = opts.server ? [opts.server.command, ...opts.server.args] : ['npx', '@wenrwa/marketplace-mcp'];
|
|
29
38
|
const args = [
|
|
30
39
|
'mcp', 'add', '--scope', 'user', exports.MCP_SERVER_NAME,
|
|
31
40
|
'-e', `WENRWA_CONNECT_TOKEN=${opts.connectToken}`,
|
|
32
41
|
'-e', `MARKETPLACE_API_URL=${opts.apiUrl}`,
|
|
33
|
-
'--',
|
|
42
|
+
'--', ...server,
|
|
34
43
|
];
|
|
35
44
|
await opts.run('claude', args);
|
|
36
|
-
|
|
45
|
+
const action = !exists ? 'registered' : opts.forceUpdate ? 're-registered' : 'migrated';
|
|
46
|
+
return { action, command: `claude ${args.join(' ')}` };
|
|
47
|
+
}
|
|
48
|
+
/** The `claude mcp list` line for our server, e.g.
|
|
49
|
+
* `wenrwa-marketplace: npx @wenrwa/marketplace-mcp - ✓ Connected`. */
|
|
50
|
+
function registrationLine(listOutput) {
|
|
51
|
+
for (const raw of listOutput.split(/\r?\n/)) {
|
|
52
|
+
const line = raw.trim();
|
|
53
|
+
if (line.startsWith(`${exports.MCP_SERVER_NAME}:`))
|
|
54
|
+
return line;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
37
57
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TermSnapshot } from './termSnapshot';
|
|
2
2
|
import { LaunchSupervisorOptions } from './launchSupervisor';
|
|
3
|
+
import { LaunchGateOptions } from './launchGate';
|
|
3
4
|
export interface PtyLike {
|
|
4
5
|
write(data: string): void;
|
|
5
6
|
resize(cols: number, rows: number): void;
|
|
@@ -88,6 +89,15 @@ export interface SessionManagerOptions {
|
|
|
88
89
|
* self-healing retry for a session-id/resume mismatch, else a plain
|
|
89
90
|
* "Claude couldn't start" line. Injectable clock/settle for tests. */
|
|
90
91
|
launchSupervisor?: LaunchSupervisorOptions;
|
|
92
|
+
/** Staggers agent start-ups (launchGate.ts): at most 2 starting at once,
|
|
93
|
+
* the rest queue with a "Starting… (N ahead)" line. Options for the
|
|
94
|
+
* default gate, or `false` to spawn every launch immediately. */
|
|
95
|
+
launchGate?: LaunchGateOptions | false;
|
|
96
|
+
/** Hosted container lane: every PTY is a $SHELL that autolaunch.sh turns
|
|
97
|
+
* into `claude`, so every open is an agent launch and goes through the
|
|
98
|
+
* gate. Off (laptop) → only an explicit `command: 'claude'` is gated and
|
|
99
|
+
* plain shells are never delayed. */
|
|
100
|
+
everyOpenIsAgentLaunch?: boolean;
|
|
91
101
|
}
|
|
92
102
|
/**
|
|
93
103
|
* lws-07 (ADR-0033 port): validates a client-supplied size pair for the
|
|
@@ -112,6 +122,8 @@ export declare class SessionManager {
|
|
|
112
122
|
private cancelTimeout;
|
|
113
123
|
private snapshotFactory;
|
|
114
124
|
private launchSupervisor;
|
|
125
|
+
private launchGate;
|
|
126
|
+
private everyOpenIsAgentLaunch;
|
|
115
127
|
/** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
|
|
116
128
|
* or a serialize-wedge whose recovery retry ALSO timed out) since this
|
|
117
129
|
* SessionManager was created. Surfaced via attachFailures() into the
|
|
@@ -22,6 +22,7 @@ exports.validSize = validSize;
|
|
|
22
22
|
const crypto_1 = __importDefault(require("crypto"));
|
|
23
23
|
const termSnapshot_1 = require("./termSnapshot");
|
|
24
24
|
const launchSupervisor_1 = require("./launchSupervisor");
|
|
25
|
+
const launchGate_1 = require("./launchGate");
|
|
25
26
|
/** Default settle window (lws-07): after a resize-on-attach, how long we give
|
|
26
27
|
* the PTY's SIGWINCH-triggered repaint to land in the snapshot before
|
|
27
28
|
* serializing. Small — this only needs to cover one render tick, not a
|
|
@@ -75,6 +76,8 @@ class SessionManager {
|
|
|
75
76
|
cancelTimeout;
|
|
76
77
|
snapshotFactory;
|
|
77
78
|
launchSupervisor;
|
|
79
|
+
launchGate;
|
|
80
|
+
everyOpenIsAgentLaunch;
|
|
78
81
|
/** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
|
|
79
82
|
* or a serialize-wedge whose recovery retry ALSO timed out) since this
|
|
80
83
|
* SessionManager was created. Surfaced via attachFailures() into the
|
|
@@ -92,6 +95,8 @@ class SessionManager {
|
|
|
92
95
|
this.cancelTimeout = opts.cancelTimeout ?? ((h) => clearTimeout(h));
|
|
93
96
|
this.snapshotFactory = opts.snapshotFactory ?? termSnapshot_1.createTermSnapshot;
|
|
94
97
|
this.launchSupervisor = opts.launchSupervisor ?? {};
|
|
98
|
+
this.launchGate = opts.launchGate === false ? null : new launchGate_1.LaunchGate(opts.launchGate ?? {});
|
|
99
|
+
this.everyOpenIsAgentLaunch = opts.everyOpenIsAgentLaunch ?? false;
|
|
95
100
|
}
|
|
96
101
|
/** twr-16: see attachFailureCount. */
|
|
97
102
|
attachFailures() {
|
|
@@ -129,9 +134,16 @@ class SessionManager {
|
|
|
129
134
|
// A `claude` launch (agent panes, crew members) is supervised through its
|
|
130
135
|
// startup window so a fast failure heals or explains itself; a shell is
|
|
131
136
|
// spawned exactly as before.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
// Agent launches are also staggered (launchGate.ts) so a burst of opens
|
|
138
|
+
// doesn't boot every `claude` in the same second; a shell never waits.
|
|
139
|
+
const isClaude = opts.command === 'claude';
|
|
140
|
+
const spawnOne = (size) => isClaude
|
|
141
|
+
? (0, launchSupervisor_1.superviseLaunch)(this.factory, { ...opts, ...size }, this.launchSupervisor)
|
|
142
|
+
: this.factory.spawn({ ...opts, ...size });
|
|
143
|
+
const gated = this.launchGate && (isClaude || this.everyOpenIsAgentLaunch);
|
|
144
|
+
const pty = gated
|
|
145
|
+
? this.launchGate.launch(spawnOne, { cols: opts.cols, rows: opts.rows })
|
|
146
|
+
: spawnOne({ cols: opts.cols, rows: opts.rows });
|
|
135
147
|
const snapshot = this.snapshotFactory(opts.cols, opts.rows, { scrollback: this.snapshotScrollback });
|
|
136
148
|
const session = {
|
|
137
149
|
pty,
|
package/dist/commands/bridge.js
CHANGED
|
@@ -69,6 +69,7 @@ const loginStatus_1 = require("../bridge/loginStatus");
|
|
|
69
69
|
const realPty_1 = require("../bridge/realPty");
|
|
70
70
|
const config_1 = require("../bridge/config");
|
|
71
71
|
const mcpRegister_1 = require("../bridge/mcpRegister");
|
|
72
|
+
const mcpInstall_1 = require("../bridge/mcpInstall");
|
|
72
73
|
const autostart_1 = require("../bridge/autostart");
|
|
73
74
|
const repoCheckout_1 = require("../bridge/repoCheckout");
|
|
74
75
|
const bundleInstaller_1 = require("../bridge/bundleInstaller");
|
|
@@ -480,7 +481,9 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
480
481
|
return;
|
|
481
482
|
}
|
|
482
483
|
if (relayMode.mode === 'relay') {
|
|
483
|
-
|
|
484
|
+
// Every container PTY is a $SHELL that autolaunch.sh turns into
|
|
485
|
+
// `claude`, so every open is an agent launch and is staggered.
|
|
486
|
+
const sessions = new sessionManager_1.SessionManager((0, realPty_1.realPtyFactory)(), { everyOpenIsAgentLaunch: true });
|
|
484
487
|
// pucc-03 (ADR-0031 model B): one container, many workspaces. Each
|
|
485
488
|
// workspace's repo lives under /data/repos/<workspaceId> and is cloned
|
|
486
489
|
// on demand the first time a terminal for it opens. The entrypoint can't
|
|
@@ -966,6 +969,29 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
966
969
|
console.log(chalk_1.default.green(`✓ Connected this machine as device "${deviceResult.deviceName}" (~/.wen-market/bridge-config.json)`));
|
|
967
970
|
}
|
|
968
971
|
}
|
|
972
|
+
// 1.9. A pinned, installed MCP server (mcpInstall.ts) so each `claude`
|
|
973
|
+
// launch starts node on a file on disk instead of running `npx` — seven
|
|
974
|
+
// agents opened together used to mean seven concurrent `npm exec`s.
|
|
975
|
+
const mcpDir = path.join(os.homedir(), '.wen-market', 'mcp');
|
|
976
|
+
const mcpInstall = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: mcpDir }, {
|
|
977
|
+
runNpm: (args) => new Promise((resolve) => {
|
|
978
|
+
(0, child_process_1.execFile)(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { timeout: 120_000 }, (err) => {
|
|
979
|
+
resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0 });
|
|
980
|
+
});
|
|
981
|
+
}),
|
|
982
|
+
exists: (p) => fs.existsSync(p),
|
|
983
|
+
readFile: (p) => fs.readFileSync(p, 'utf-8'),
|
|
984
|
+
writeFile: (p, c) => fs.writeFileSync(p, c),
|
|
985
|
+
mkdirp: (d) => fs.mkdirSync(d, { recursive: true }),
|
|
986
|
+
now: Date.now,
|
|
987
|
+
nodePath: process.execPath,
|
|
988
|
+
});
|
|
989
|
+
if (mcpInstall.ok && mcpInstall.action === 'installed')
|
|
990
|
+
console.log(chalk_1.default.green(`✓ Installed MCP server ${mcpInstall_1.MCP_PINNED_VERSION} (${mcpDir})`));
|
|
991
|
+
else if (mcpInstall.ok && mcpInstall.action === 'stale-copy')
|
|
992
|
+
console.log(chalk_1.default.yellow(`! Could not install MCP server ${mcpInstall_1.MCP_PINNED_VERSION} (${mcpInstall.error}) — using installed ${mcpInstall.version}`));
|
|
993
|
+
else if (!mcpInstall.ok)
|
|
994
|
+
console.log(chalk_1.default.yellow(`! Could not install MCP server (${mcpInstall.error}) — falling back to npx @wenrwa/marketplace-mcp (slower agent start-up)`));
|
|
969
995
|
// 2. Idempotent MCP registration into the user's Claude Code — loud.
|
|
970
996
|
const mcp = await (0, mcpRegister_1.registerMcpServer)({
|
|
971
997
|
connectToken: config.connectToken,
|
|
@@ -973,9 +999,10 @@ exports.bridgeCommand = new commander_1.Command('bridge')
|
|
|
973
999
|
run: execRunner,
|
|
974
1000
|
// A token passed on the command line is fresh — replace any stale registration.
|
|
975
1001
|
forceUpdate: Boolean(opts.token),
|
|
1002
|
+
...(mcpInstall.ok ? { server: { command: mcpInstall.command, args: mcpInstall.args } } : {}),
|
|
976
1003
|
});
|
|
977
|
-
if (mcp.action === 'registered' || mcp.action === 're-registered')
|
|
978
|
-
console.log(chalk_1.default.green(`✓ ${mcp.action === 're-registered' ? 'Re-registered' : 'Registered'} MCP server: ${(0, mcpRegister_1.redactToken)(mcp.command ?? '')}`));
|
|
1004
|
+
if (mcp.action === 'registered' || mcp.action === 're-registered' || mcp.action === 'migrated')
|
|
1005
|
+
console.log(chalk_1.default.green(`✓ ${mcp.action === 're-registered' ? 'Re-registered' : mcp.action === 'migrated' ? 'Migrated (npx → installed)' : 'Registered'} MCP server: ${(0, mcpRegister_1.redactToken)(mcp.command ?? '')}`));
|
|
979
1006
|
else if (mcp.action === 'already-registered')
|
|
980
1007
|
console.log(chalk_1.default.dim('• wenrwa-marketplace MCP server already registered — skipped'));
|
|
981
1008
|
else
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenrwa/marketplace-cli",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "CLI tool for the Wenrwa Agent Marketplace
|
|
3
|
+
"version": "0.7.6",
|
|
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",
|
|
7
7
|
"homepage": "https://marketplace.wenrwa.com",
|